diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt new file mode 100644 index 0000000000..11185f019e --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/QuickSettings.kt @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.dialogs + +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.RadioGroup +import android.widget.TextView +import androidx.drawerlayout.widget.DrawerLayout +import com.google.android.material.color.MaterialColors +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.fragments.EmulationFragment +import org.yuzu.yuzu_emu.utils.NativeConfig +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractShortSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting + +class QuickSettings(val emulationFragment: EmulationFragment) { + // Kinda a crappy workaround to get a title from setting keys + // Idk how to do this witthout hardcoding every single one + private fun getSettingTitle(settingKey: String): String { + return settingKey.replace("_", " ").split(" ") + .joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } } + + } + + private fun saveSettings() { + if (emulationFragment.shouldUseCustom) { + NativeConfig.savePerGameConfig() + } else { + NativeConfig.saveGlobalConfig() + } + } + + fun addPerGameConfigStatusIndicator(container: ViewGroup) { + val inflater = LayoutInflater.from(emulationFragment.requireContext()) + val statusView = inflater.inflate(R.layout.item_quick_settings_status, container, false) + + val statusIcon = statusView.findViewById(R.id.status_icon) + val statusText = statusView.findViewById(R.id.status_text) + + statusIcon.setImageResource(R.drawable.ic_settings_outline) + statusText.text = emulationFragment.getString(R.string.using_per_game_config) + statusText.setTextColor( + MaterialColors.getColor( + statusText, + com.google.android.material.R.attr.colorPrimary + ) + ) + + container.addView(statusView) + } + + // settings + + fun addIntSetting( + container: ViewGroup, + setting: IntSetting, + namesArrayId: Int, + valuesArrayId: Int + ) { + val inflater = LayoutInflater.from(emulationFragment.requireContext()) + val itemView = inflater.inflate(R.layout.item_quick_settings_menu, container, false) + val headerView = itemView.findViewById(R.id.setting_header) + val titleView = itemView.findViewById(R.id.setting_title) + val valueView = itemView.findViewById(R.id.setting_value) + val expandIcon = itemView.findViewById(R.id.expand_icon) + val radioGroup = itemView.findViewById(R.id.radio_group) + + titleView.text = getSettingTitle(setting.key) + + val names = emulationFragment.resources.getStringArray(namesArrayId) + val values = emulationFragment.resources.getIntArray(valuesArrayId) + val currentIndex = values.indexOf(setting.getInt()) + + valueView.text = if (currentIndex >= 0) names[currentIndex] else "Null" + headerView.visibility = View.VISIBLE + + var isExpanded = false + names.forEachIndexed { index, name -> + val radioButton = com.google.android.material.radiobutton.MaterialRadioButton(emulationFragment.requireContext()) + radioButton.text = name + radioButton.id = View.generateViewId() + radioButton.isChecked = index == currentIndex + radioButton.setPadding(16, 8, 16, 8) + + radioButton.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + setting.setInt(values[index]) + saveSettings() + valueView.text = name + } + } + radioGroup.addView(radioButton) + } + + headerView.setOnClickListener { + isExpanded = !isExpanded + if (isExpanded) { + radioGroup.visibility = View.VISIBLE + expandIcon.animate().rotation(180f).setDuration(200).start() + } else { + radioGroup.visibility = View.GONE + expandIcon.animate().rotation(0f).setDuration(200).start() + } + } + + container.addView(itemView) + } + + fun addBooleanSetting( + container: ViewGroup, + setting: BooleanSetting + ) { + val inflater = LayoutInflater.from(emulationFragment.requireContext()) + val itemView = inflater.inflate(R.layout.item_quick_settings_menu, container, false) + + val switchContainer = itemView.findViewById(R.id.switch_container) + val titleView = itemView.findViewById(R.id.switch_title) + val switchView = itemView.findViewById(R.id.setting_switch) + + titleView.text = getSettingTitle(setting.key) + switchContainer.visibility = View.VISIBLE + switchView.isChecked = setting.getBoolean() + + switchView.setOnCheckedChangeListener { _, isChecked -> + setting.setBoolean(isChecked) + saveSettings() + } + + switchContainer.setOnClickListener { + switchView.toggle() + } + container.addView(itemView) + } + + fun addSliderSetting( + container: ViewGroup, + setting: AbstractSetting, + minValue: Int = 0, + maxValue: Int = 100, + units: String = "" + ) { + val inflater = LayoutInflater.from(emulationFragment.requireContext()) + val itemView = inflater.inflate(R.layout.item_quick_settings_menu, container, false) + + val sliderContainer = itemView.findViewById(R.id.slider_container) + val titleView = itemView.findViewById(R.id.slider_title) + val valueDisplay = itemView.findViewById(R.id.slider_value_display) + val slider = itemView.findViewById(R.id.setting_slider) + + + titleView.text = getSettingTitle(setting.key) + sliderContainer.visibility = View.VISIBLE + + slider.valueFrom = minValue.toFloat() + slider.valueTo = maxValue.toFloat() + slider.stepSize = 1f + val currentValue = when (setting) { + is AbstractShortSetting -> setting.getShort(needsGlobal = false).toInt() + is AbstractIntSetting -> setting.getInt(needsGlobal = false) + else -> 0 + } + slider.value = currentValue.toFloat().coerceIn(minValue.toFloat(), maxValue.toFloat()) + + val displayValue = "${slider.value.toInt()}$units" + valueDisplay.text = displayValue + + slider.addOnChangeListener { _, value, chanhed -> + if (chanhed) { + val intValue = value.toInt() + when (setting) { + is AbstractShortSetting -> setting.setShort(intValue.toShort()) + is AbstractIntSetting -> setting.setInt(intValue) + } + saveSettings() + valueDisplay.text = "$intValue$units" + } + } + + slider.setOnTouchListener { _, event -> + val drawer = emulationFragment.view?.findViewById(R.id.drawer_layout) + when (event.action) { + MotionEvent.ACTION_DOWN -> { + drawer?.requestDisallowInterceptTouchEvent(true) + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + drawer?.requestDisallowInterceptTouchEvent(false) + } + } + false + } + + container.addView(itemView) + } + + fun addDivider(container: ViewGroup) { + val inflater = LayoutInflater.from(emulationFragment.requireContext()) + val dividerView = inflater.inflate(R.layout.item_quick_settings_divider, container, false) + container.addView(dividerView) + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index 8ef85a6ff9..bad11f8628 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2023 yuzu Emulator Project @@ -68,12 +68,14 @@ import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.activities.EmulationActivity import org.yuzu.yuzu_emu.databinding.DialogOverlayAdjustBinding import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding +import org.yuzu.yuzu_emu.dialogs.QuickSettings import org.yuzu.yuzu_emu.features.input.NativeInput import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting import org.yuzu.yuzu_emu.features.settings.model.IntSetting import org.yuzu.yuzu_emu.features.settings.model.Settings import org.yuzu.yuzu_emu.features.settings.model.Settings.EmulationOrientation import org.yuzu.yuzu_emu.features.settings.model.Settings.EmulationVerticalAlignment +import org.yuzu.yuzu_emu.features.settings.model.ShortSetting import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile import org.yuzu.yuzu_emu.model.DriverViewModel import org.yuzu.yuzu_emu.model.EmulationViewModel @@ -96,6 +98,7 @@ import java.io.ByteArrayOutputStream import java.io.File import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +import kotlin.or class EmulationFragment : Fragment(), SurfaceHolder.Callback { private lateinit var emulationState: EmulationState @@ -136,6 +139,10 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { private var wasInputOverlayAutoHidden = false private var overlayTouchActive = false + var shouldUseCustom = false + private var isQuickSettingsMenuOpen = false + private val quickSettings = QuickSettings(this) + private val loadAmiiboLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> isAmiiboPickerOpen = false @@ -283,7 +290,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { // Normal game launch from arguments else -> { - val shouldUseCustom = game?.let { it == args.game && args.custom } ?: false + shouldUseCustom = game?.let { it == args.game && args.custom } ?: false if (shouldUseCustom) { SettingsFile.loadCustomConfig(game!!) @@ -659,6 +666,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { binding.inGameMenu.requestFocus() emulationViewModel.setDrawerOpen(true) updateQuickOverlayMenuEntry(BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean()) + if (drawerView == binding.inGameMenu) { + binding.drawerLayout.closeDrawer(binding.quickSettingsSheet) + } else if (drawerView == binding.quickSettingsSheet) { + binding.drawerLayout.closeDrawer(binding.inGameMenu) + } } override fun onDrawerClosed(drawerView: View) { @@ -726,16 +738,23 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { Settings.MenuTag.SECTION_ROOT ) binding.inGameMenu.requestFocus() + binding.drawerLayout.closeDrawer(binding.quickSettingsSheet) binding.root.findNavController().navigate(action) true } + R.id.menu_quick_settings -> { + openQuickSettingsMenu() + true + } + R.id.menu_settings_per_game -> { val action = HomeNavigationDirections.actionGlobalSettingsActivity( args.game, Settings.MenuTag.SECTION_ROOT ) binding.inGameMenu.requestFocus() + binding.drawerLayout.closeDrawer(binding.quickSettingsSheet) binding.root.findNavController().navigate(action) true } @@ -801,6 +820,36 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } + addQuickSettings() + + binding.drawerLayout.addDrawerListener(object : DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { + // no op + } + + override fun onDrawerOpened(drawerView: View) { + if (drawerView == binding.quickSettingsSheet) { + isQuickSettingsMenuOpen = true + if (shouldUseCustom) { + SettingsFile.loadCustomConfig(args.game!!) + } + } + } + + override fun onDrawerClosed(drawerView: View) { + if (drawerView == binding.quickSettingsSheet) { + isQuickSettingsMenuOpen = false + if (shouldUseCustom) { + NativeConfig.unloadPerGameConfig() + } + } + } + + override fun onDrawerStateChanged(newState: Int) { + // No op + } + }) + setInsets() requireActivity().onBackPressedDispatcher.addCallback( @@ -979,6 +1028,73 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { } } + private fun addQuickSettings() { + binding.quickSettingsSheet.apply { + val container = binding.quickSettingsSheet.findViewById(R.id.quick_settings_container) + + container.removeAllViews() + + if (shouldUseCustom) { + quickSettings.addPerGameConfigStatusIndicator(container) + } + + quickSettings.addBooleanSetting( + container, + BooleanSetting.RENDERER_USE_SPEED_LIMIT, + ) + + quickSettings.addSliderSetting( + container, + ShortSetting.RENDERER_SPEED_LIMIT, + minValue = 0, + maxValue = 400, + units = "%", + ) + + quickSettings.addBooleanSetting( + container, + BooleanSetting.USE_DOCKED_MODE, + ) + + quickSettings.addDivider(container) + + quickSettings.addIntSetting( + container, + IntSetting.RENDERER_ACCURACY, + R.array.rendererAccuracyNames, + R.array.rendererAccuracyValues + ) + + + quickSettings.addIntSetting( + container, + IntSetting.RENDERER_SCALING_FILTER, + R.array.rendererScalingFilterNames, + R.array.rendererScalingFilterValues + ) + + quickSettings.addSliderSetting( + container, + IntSetting.FSR_SHARPENING_SLIDER, + minValue = 0, + maxValue = 100, + units = "%" + ) + + quickSettings.addIntSetting( + container, + IntSetting.RENDERER_ANTI_ALIASING, + R.array.rendererAntiAliasingNames, + R.array.rendererAntiAliasingValues + ) + } + } + + private fun openQuickSettingsMenu() { + binding.drawerLayout.closeDrawer(binding.inGameMenu) + binding.drawerLayout.openDrawer(binding.quickSettingsSheet) + } + private fun updateQuickOverlayMenuEntry(isVisible: Boolean) { val b = _binding ?: return val item = b.inGameMenu.menu.findItem(R.id.menu_quick_overlay) ?: return @@ -1151,6 +1267,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { // we need to reinitialize the auto-hide timer initializeOverlayAutoHide() + addQuickSettings() } private fun resetInputOverlay() { @@ -1809,6 +1926,26 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { windowInsets } + + ViewCompat.setOnApplyWindowInsetsListener(binding.quickSettingsSheet) { v, insets -> + val systemBarsInsets: Insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + if (v.layoutDirection == View.LAYOUT_DIRECTION_LTR) { + v.setPadding( + systemBarsInsets.left, + systemBarsInsets.top, + 0, + systemBarsInsets.bottom + ) + } else { + v.setPadding( + 0, + systemBarsInsets.top, + systemBarsInsets.right, + systemBarsInsets.bottom + ) + } + insets + } } private class EmulationState( diff --git a/src/android/app/src/main/res/layout/fragment_emulation.xml b/src/android/app/src/main/res/layout/fragment_emulation.xml index a5ebd2df3a..7f5f039d5e 100644 --- a/src/android/app/src/main/res/layout/fragment_emulation.xml +++ b/src/android/app/src/main/res/layout/fragment_emulation.xml @@ -171,14 +171,27 @@ + tools:visibility="gone"> + + + + + + diff --git a/src/android/app/src/main/res/layout/item_quick_settings_divider.xml b/src/android/app/src/main/res/layout/item_quick_settings_divider.xml new file mode 100644 index 0000000000..1c05181aa4 --- /dev/null +++ b/src/android/app/src/main/res/layout/item_quick_settings_divider.xml @@ -0,0 +1,5 @@ + + diff --git a/src/android/app/src/main/res/layout/item_quick_settings_menu.xml b/src/android/app/src/main/res/layout/item_quick_settings_menu.xml new file mode 100644 index 0000000000..bcb3091037 --- /dev/null +++ b/src/android/app/src/main/res/layout/item_quick_settings_menu.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/item_quick_settings_status.xml b/src/android/app/src/main/res/layout/item_quick_settings_status.xml new file mode 100644 index 0000000000..5b85bcd6f7 --- /dev/null +++ b/src/android/app/src/main/res/layout/item_quick_settings_status.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/layout_quick_settings.xml b/src/android/app/src/main/res/layout/layout_quick_settings.xml new file mode 100644 index 0000000000..0e6f75d76e --- /dev/null +++ b/src/android/app/src/main/res/layout/layout_quick_settings.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/menu/menu_in_game.xml b/src/android/app/src/main/res/menu/menu_in_game.xml index 70fb48c13b..c1d8147f84 100644 --- a/src/android/app/src/main/res/menu/menu_in_game.xml +++ b/src/android/app/src/main/res/menu/menu_in_game.xml @@ -18,6 +18,11 @@ android:icon="@drawable/ic_settings" android:title="@string/preferences_settings" /> + + Value must be at most %1$d Invalid value + Using Per-Game Config Show Input Overlay @@ -709,6 +710,7 @@ Docked mode, region, language Graphics Accuracy level, resolution, shader cache + Quick Settings Audio Output engine, volume Controls