diff options
Diffstat (limited to 'Source/Android/app/src/main/java')
32 files changed, 1917 insertions, 715 deletions
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt index b52b7b4fa4..e0ada502cc 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt @@ -367,6 +367,9 @@ object NativeLibrary { external fun PauseEmulation(overrideAchievementRestrictions: Boolean) @JvmStatic + external fun ResetEmulation() + + @JvmStatic external fun StopEmulation() /** diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt index 8a1d8addbd..0145887b78 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt @@ -42,7 +42,9 @@ import org.dolphinemu.dolphinemu.features.infinitybase.ui.FigureSlot import org.dolphinemu.dolphinemu.features.infinitybase.ui.FigureSlotAdapter import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface import org.dolphinemu.dolphinemu.features.input.model.DolphinSensorEventListener +import org.dolphinemu.dolphinemu.features.input.model.Hotkeys import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting +import org.dolphinemu.dolphinemu.utils.ContentHandler import org.dolphinemu.dolphinemu.features.settings.model.IntSetting import org.dolphinemu.dolphinemu.features.settings.model.Settings import org.dolphinemu.dolphinemu.features.settings.model.StringSetting @@ -104,17 +106,21 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { } val requestSkylanderFile = registerForActivityResult( - ActivityResultContracts.GetContent() + ActivityResultContracts.OpenDocument() ) { uri: Uri? -> if (uri != null) { val slot = SkylanderConfig.loadSkylander( skylanderSlots[skylanderSlot].portalSlot, uri.toString() - )!! - clearSkylander(skylanderSlot) - skylanderSlots[skylanderSlot].portalSlot = slot.first!! - skylanderSlots[skylanderSlot].label = slot.second!! - skylandersBinding.figureManager.adapter!!.notifyItemChanged(skylanderSlot) + ) + if (slot != null && slot.first != null && slot.second != null) { + clearSkylander(skylanderSlot) + skylanderSlots[skylanderSlot].portalSlot = slot.first!! + skylanderSlots[skylanderSlot].label = slot.second!! + skylandersBinding.figureManager.adapter?.notifyItemChanged(skylanderSlot) + } else { + Toast.makeText(this, R.string.skylander_load_failed, Toast.LENGTH_SHORT).show() + } skylanderSlot = -1 skylanderData = Skylander.BLANK_SKYLANDER } @@ -131,10 +137,12 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { uri.toString(), skylanderSlots[skylanderSlot].portalSlot ) - clearSkylander(skylanderSlot) - skylanderSlots[skylanderSlot].portalSlot = slot.first - skylanderSlots[skylanderSlot].label = slot.second - skylandersBinding.figureManager.adapter?.notifyItemChanged(skylanderSlot) + if (slot.first != -1) { + clearSkylander(skylanderSlot) + skylanderSlots[skylanderSlot].portalSlot = slot.first + skylanderSlots[skylanderSlot].label = slot.second + skylandersBinding.figureManager.adapter?.notifyItemChanged(skylanderSlot) + } skylanderSlot = -1 skylanderData = Skylander.BLANK_SKYLANDER } @@ -142,7 +150,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { } val requestInfinityFigureFile = registerForActivityResult( - ActivityResultContracts.GetContent() + ActivityResultContracts.OpenDocument() ) { uri: Uri? -> if (uri != null) { val label = InfinityConfig.loadFigure(infinityPosition, uri.toString()) @@ -312,6 +320,13 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { } DolphinSensorEventListener.setDeviceRotation(windowManager.defaultDisplay.rotation) + + Hotkeys.setEnabled(true) + } + + override fun onPause() { + Hotkeys.setEnabled(false) + super.onPause() } override fun onStop() { @@ -518,6 +533,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { MENU_ACTION_SKYLANDERS -> showSkylanderPortalSettings() MENU_ACTION_INFINITY_BASE -> showInfinityBaseSettings() MENU_ACTION_EXIT -> emulationFragment!!.stopEmulation() + MENU_ACTION_RESET -> emulationFragment!!.resetEmulation() } } @@ -1077,6 +1093,7 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { const val MENU_ACTION_SKYLANDERS = 36 const val MENU_ACTION_INFINITY_BASE = 37 const val MENU_ACTION_LATCHING_CONTROLS = 38 + const val MENU_ACTION_RESET = 39; init { buttonsActionsMap.apply { diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/SettingsRowPresenter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/SettingsRowPresenter.kt index f67a23e2da..eabfb75f82 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/SettingsRowPresenter.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/SettingsRowPresenter.kt @@ -3,6 +3,7 @@ package org.dolphinemu.dolphinemu.adapters import android.view.ViewGroup +import android.widget.TextView import androidx.core.content.ContextCompat import androidx.leanback.widget.ImageCardView import androidx.leanback.widget.Presenter @@ -16,9 +17,16 @@ class SettingsRowPresenter : Presenter() { val settingsCard = ImageCardView(parent.context) settingsCard.apply { setMainImageAdjustViewBounds(true) - setMainImageDimensions(192, 160) + setMainImageDimensions( + resources.getDimensionPixelSize(R.dimen.tv_settings_card_width), + resources.getDimensionPixelSize(R.dimen.tv_settings_card_height) + ) isFocusable = true isFocusableInTouchMode = true + findViewById<TextView>(R.id.title_text).apply { + minLines = 2 + maxLines = 2 + } } // Use that view to create a ViewHolder. diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/infinitybase/ui/FigureSlotAdapter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/infinitybase/ui/FigureSlotAdapter.kt index 0613274c30..6839d6f803 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/infinitybase/ui/FigureSlotAdapter.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/infinitybase/ui/FigureSlotAdapter.kt @@ -51,7 +51,7 @@ class FigureSlotAdapter( holder.binding.buttonLoadFigure.setOnClickListener { activity.setInfinityFigureData(0, "", figure.position, position) - activity.requestInfinityFigureFile.launch("*/*") + activity.requestInfinityFigureFile.launch(arrayOf("*/*")) } val inflater = LayoutInflater.from(activity) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt index b412b6ef9b..bcd47781a0 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt @@ -186,7 +186,7 @@ object ControllerInterface { private fun vibrate(vibrator: Vibrator) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { vibrator.vibrate( - VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE), + VibrationEffect.createOneShot(10000, VibrationEffect.DEFAULT_AMPLITUDE), VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_MEDIA).build() ) } else { @@ -194,16 +194,22 @@ object ControllerInterface { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @Suppress("DEPRECATION") vibrator.vibrate( - VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE), + VibrationEffect.createOneShot(10000, VibrationEffect.DEFAULT_AMPLITUDE), attributes ) } else { @Suppress("DEPRECATION") - vibrator.vibrate(100, attributes) + vibrator.vibrate(10000, attributes) } } } + @Keep + @JvmStatic + private fun cancelVibration(vibrator: Vibrator) { + vibrator.cancel() + } + private class InputDeviceListener : InputManager.InputDeviceListener { override external fun onInputDeviceAdded(deviceId: Int) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/Hotkeys.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/Hotkeys.kt new file mode 100644 index 0000000000..7f0100238b --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/Hotkeys.kt @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.input.model + +object Hotkeys { + /** + * Stops or resumes the native hotkey scheduler acting on presses. + */ + @JvmStatic + external fun setEnabled(enabled: Boolean) +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/controlleremu/EmulatedController.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/controlleremu/EmulatedController.kt index e0938b975d..00c06cb896 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/controlleremu/EmulatedController.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/controlleremu/EmulatedController.kt @@ -56,6 +56,12 @@ class EmulatedController private constructor(private val pointer: Long) : Contro external fun getWiimote(controllerIndex: Int): EmulatedController @JvmStatic + external fun getHotkeys(): EmulatedController + + @JvmStatic + external fun getSupportedHotkeyIndices(): IntArray + + @JvmStatic external fun getWiimoteAttachment( controllerIndex: Int, attachmentIndex: Int diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt index 56676c4d3f..8e6ac314ff 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext +import org.dolphinemu.dolphinemu.features.netplay.model.ControllerMapping import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player @@ -87,6 +88,12 @@ class NetplaySession( ) val players = _players.asSharedFlow().distinctUntilChanged() + private val _controllerMapping = MutableSharedFlow<ControllerMapping>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val controllerMapping = _controllerMapping.asSharedFlow().distinctUntilChanged() + private val _chatMessages = MutableSharedFlow<String>( extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST @@ -179,12 +186,23 @@ class NetplaySession( fun startGame() = nativeStartGame() + fun computeGameDigest() = nativeComputeGameDigest() + + fun computeSDCardDigest() = nativeComputeSDCardDigest() + + fun abortGameDigest() = nativeAbortGameDigest() + fun getPort(): Int = nativeGetPort() fun getExternalIpAddress(): String? = nativeGetExternalIpAddress() fun reconnectTraversal() = nativeReconnectTraversal() + fun setControllerMapping(mapping: ControllerMapping) { + nativeSetPadMapping(mapping.gamecubePorts.map { it?.pid ?: 0 }.toIntArray()) + nativeSetWiimoteMapping(mapping.wiiRemotes.map { it?.pid ?: 0 }.toIntArray()) + } + fun consumeBootSessionData(): Long { return bootSessionDataPointer.also { bootSessionDataPointer = 0 @@ -272,12 +290,26 @@ class NetplaySession( private external fun nativeStartGame() + private external fun nativeComputeGameDigest() + + private external fun nativeComputeSDCardDigest() + + private external fun nativeAbortGameDigest() + private external fun nativeGetPort(): Int private external fun nativeGetExternalIpAddress(): String? private external fun nativeReconnectTraversal() + private external fun nativeGetPadMapping(): IntArray + + private external fun nativeGetWiimoteMapping(): IntArray + + private external fun nativeSetPadMapping(mapping: IntArray) + + private external fun nativeSetWiimoteMapping(mapping: IntArray) + // NetPlayUI callbacks @Keep @@ -304,7 +336,16 @@ class NetplaySession( @Keep fun onUpdate(players: Array<Player>) { - _players.tryEmit(players.toList()) + val playersList = players.toList() + _players.tryEmit(playersList) + + fun findPlayer(playerId: Int) = playersList.find { it.pid == playerId } + _controllerMapping.tryEmit( + ControllerMapping( + gamecubePorts = nativeGetPadMapping().map(::findPlayer).toList(), + wiiRemotes = nativeGetWiimoteMapping().map(::findPlayer).toList(), + ) + ) } @Keep @@ -381,7 +422,7 @@ class NetplaySession( result = null, ) } ?: emptyList(), - matches = null, + finished = false, ) } @@ -398,18 +439,12 @@ class NetplaySession( @Keep fun onSetGameDigestResult(playerId: Int, result: String) { val current = _gameDigestProgress.value ?: return - val updated = current.copy( + _gameDigestProgress.value = current.copy( playerProgresses = current.playerProgresses.map { if (it.playerId == playerId) it.copy(result = result) else it - } + }, + finished = true, ) - val finished = updated.playerProgresses.all { it.result != null } - _gameDigestProgress.value = if (finished) { - val results = updated.playerProgresses.map { it.result } - updated.copy(matches = results.distinct().size == 1) - } else { - updated - } } /** diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ControllerMapping.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ControllerMapping.kt new file mode 100644 index 0000000000..d7cfae0ab3 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ControllerMapping.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.netplay.model + +data class ControllerMapping( + val gamecubePorts: List<Player?>, + val wiiRemotes: List<Player?>, +) { + fun withGamecubePort(port: Int, player: Player?) = + copy(gamecubePorts = gamecubePorts.replace(port, player)) + + fun withWiiRemote(port: Int, player: Player?) = + copy(wiiRemotes = wiiRemotes.replace(port, player)) + + companion object { + fun emptyControllerMapping(): ControllerMapping = ControllerMapping( + gamecubePorts = emptyList(), + wiiRemotes = emptyList(), + ) + } +} + +private fun <T> List<T>.replace(index: Int, value: T) = + toMutableList().also { it[index] = value } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt index 923f03068a..861a5b99d2 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt @@ -5,8 +5,14 @@ package org.dolphinemu.dolphinemu.features.netplay.model data class GameDigestProgress( val title: String, val playerProgresses: List<PlayerProgress>, - val matches: Boolean?, + val finished: Boolean, ) { + val matches: Boolean? + get() { + val results = playerProgresses.mapNotNull { it.result } + return if (results.size >= 2) results.distinct().size == 1 else null + } + data class PlayerProgress( val playerId: Int, val name: String, diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt index adea1a13a5..1dae14b6e2 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt @@ -11,15 +11,17 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.features.netplay.NetplaySession +import org.dolphinemu.dolphinemu.features.netplay.model.ControllerMapping.Companion.emptyControllerMapping +import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting import org.dolphinemu.dolphinemu.features.settings.model.IntSetting import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig import org.dolphinemu.dolphinemu.features.settings.model.StringSetting @@ -58,6 +60,9 @@ class NetplayViewModel( val players = netplaySession.players .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + val controllerMapping = netplaySession.controllerMapping + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyControllerMapping()) + val messages = netplaySession.messages .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) @@ -104,18 +109,37 @@ class NetplayViewModel( private val _notAllPlayersHaveGame = Channel<Unit>(Channel.CONFLATED) val notAllPlayersHaveGame = _notAllPlayersHaveGame.receiveAsFlow() + private val _dualCoreWarning = Channel<Unit>(Channel.CONFLATED) + val dualCoreWarning = _dualCoreWarning.receiveAsFlow() + fun startGame() { - if (netplaySession.doAllPlayersHaveGame()) { - netplaySession.startGame() - } else { + if (!netplaySession.doAllPlayersHaveGame()) { _notAllPlayersHaveGame.trySend(Unit) + return + } + + if (BooleanSetting.MAIN_CPU_THREAD.boolean && + !BooleanSetting.NETPLAY_SKIP_DUAL_CORE_WARNING.boolean + ) { + _dualCoreWarning.trySend(Unit) + return } + + confirmStartGame() } fun confirmStartGame() { netplaySession.startGame() } + fun setDualCoreEnabled(enabled: Boolean) { + BooleanSetting.MAIN_CPU_THREAD.setBoolean(NativeConfig.LAYER_BASE, enabled) + } + + fun skipDualCoreWarning() { + BooleanSetting.NETPLAY_SKIP_DUAL_CORE_WARNING.setBoolean(NativeConfig.LAYER_BASE, true) + } + fun sendMessage(message: String) { val trimmedMessage = message.trim() if (trimmedMessage.isEmpty()) { @@ -148,6 +172,24 @@ class NetplayViewModel( netplaySession.changeGame(gameFile) } + fun setGamecubePort(portNumber: Int, player: Player?) { + netplaySession.setControllerMapping( + controllerMapping.value.withGamecubePort(portNumber, player) + ) + } + + fun setWiiRemote(remoteNumber: Int, player: Player?) { + netplaySession.setControllerMapping( + controllerMapping.value.withWiiRemote(remoteNumber, player) + ) + } + + fun computeGameDigest() = netplaySession.computeGameDigest() + + fun computeSDCardDigest() = netplaySession.computeSDCardDigest() + + fun abortGameDigest() = netplaySession.abortGameDigest() + private fun getLocalIp(): JoinAddress { val localIp = networkHelper.getLocalIpString() ?: return JoinAddress.Unknown { _joinAddresses.value += JoinInfoType.LOCAL to getLocalIp() } @@ -176,12 +218,14 @@ class NetplayViewModel( JoinInfoType.EXTERNAL to JoinAddress.Loading, ) } + is TraversalState.Connected -> { _joinAddresses.value += mapOf( JoinInfoType.ROOM_ID to JoinAddress.Loaded(state.hostCode), JoinInfoType.EXTERNAL to JoinAddress.Loaded(state.externalAddress), ) } + is TraversalState.Failure -> { _joinAddresses.value += mapOf( JoinInfoType.ROOM_ID to JoinAddress.Unknown(retry), diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt index eb9cecec20..4916a6c7a1 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt @@ -58,6 +58,9 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { onGameSelected = viewModel::changeGame, gameFiles = viewModel.gameFiles.collectAsState().value, notAllPlayersHaveGame = viewModel.notAllPlayersHaveGame, + dualCoreWarning = viewModel.dualCoreWarning, + onSetDualCoreEnabled = viewModel::setDualCoreEnabled, + onSkipDualCoreWarning = viewModel::skipDualCoreWarning, onConfirmStartGame = viewModel::confirmStartGame, players = viewModel.players.collectAsState().value, hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value, @@ -70,6 +73,12 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { saveTransferProgress = viewModel.saveTransferProgress.collectAsState().value, gameDigestProgress = viewModel.gameDigestProgress.collectAsState().value, joinAddresses = viewModel.joinAddresses.collectAsState().value, + controllerMapping = viewModel.controllerMapping.collectAsState().value, + onGamecubePortChanged = viewModel::setGamecubePort, + onWiiRemoteChanged = viewModel::setWiiRemote, + onComputeGameDigest = viewModel::computeGameDigest, + onComputeSDCardDigest = viewModel::computeSDCardDigest, + onAbortGameDigest = viewModel::abortGameDigest, ) } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt index 3c641c6695..97f456c7a6 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt @@ -4,7 +4,9 @@ package org.dolphinemu.dolphinemu.features.netplay.ui import android.content.Intent import android.content.res.Configuration +import androidx.annotation.StringRes import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding @@ -39,11 +42,13 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Remove +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType @@ -59,6 +64,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetValue +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -72,11 +78,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight @@ -86,8 +93,8 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.sp import androidx.window.core.layout.WindowSizeClass import coil.compose.AsyncImage @@ -95,6 +102,7 @@ import coil.request.ImageRequest import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import org.dolphinemu.dolphinemu.R +import org.dolphinemu.dolphinemu.features.netplay.model.ControllerMapping import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress import org.dolphinemu.dolphinemu.features.netplay.model.JoinAddress import org.dolphinemu.dolphinemu.features.netplay.model.JoinInfoType @@ -130,6 +138,9 @@ fun NetplayScreen( onGameSelected: (GameFile) -> Unit, gameFiles: List<GameFile>, notAllPlayersHaveGame: Flow<Unit>, + dualCoreWarning: Flow<Unit>, + onSetDualCoreEnabled: (Boolean) -> Unit, + onSkipDualCoreWarning: () -> Unit, onConfirmStartGame: () -> Unit, hostInputAuthorityEnabled: Boolean, networkMode: NetworkMode, @@ -142,6 +153,12 @@ fun NetplayScreen( saveTransferProgress: SaveTransferProgress?, gameDigestProgress: GameDigestProgress?, joinAddresses: Map<JoinInfoType, JoinAddress>, + controllerMapping: ControllerMapping, + onGamecubePortChanged: (port: Int, player: Player?) -> Unit, + onWiiRemoteChanged: (port: Int, player: Player?) -> Unit, + onComputeGameDigest: () -> Unit, + onComputeSDCardDigest: () -> Unit, + onAbortGameDigest: () -> Unit, ) { val scrollState = rememberScrollState() @@ -157,6 +174,42 @@ fun NetplayScreen( ) } }, + actions = { + if (isHosting) { + var menuExpanded by remember { mutableStateOf(false) } + IconButton(onClick = { menuExpanded = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "More options", + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + Text( + text = stringResource(R.string.netplay_checksum), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.netplay_checksum_current_game)) }, + onClick = { + menuExpanded = false + onComputeGameDigest() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.netplay_checksum_sd_card)) }, + onClick = { + menuExpanded = false + onComputeSDCardDigest() + }, + ) + } + } + }, floatingActionButton = { if (isHosting) { ExtendedFloatingActionButton(onClick = onStartGame) { @@ -172,6 +225,7 @@ fun NetplayScreen( // State which must live above the landscape/portrait split. var showChat by rememberSaveable { mutableStateOf(false) } var showGamePicker by rememberSaveable { mutableStateOf(false) } + var showControllerMapping by rememberSaveable { mutableStateOf(false) } var selectedJoinInfoType by rememberSaveable { mutableStateOf(joinAddresses.keys.firstOrNull() ?: JoinInfoType.EXTERNAL) } @@ -195,6 +249,11 @@ fun NetplayScreen( showGamePicker = showGamePicker, onShowGamePickerChanged = { showGamePicker = it }, players = players, + controllerMapping = controllerMapping, + onGamecubePortChanged = onGamecubePortChanged, + onWiimotePortChanged = onWiiRemoteChanged, + showControllerMapping = showControllerMapping, + onShowControllerMappingChanged = { showControllerMapping = it }, hostInputAuthorityEnabled = hostInputAuthorityEnabled, networkMode = networkMode, onNetworkModeChanged = onNetworkModeChanged, @@ -222,6 +281,11 @@ fun NetplayScreen( showGamePicker = showGamePicker, onShowGamePickerChanged = { showGamePicker = it }, players = players, + controllerMapping = controllerMapping, + onGamecubePortChanged = onGamecubePortChanged, + onWiimotePortChanged = onWiiRemoteChanged, + showControllerMapping = showControllerMapping, + onShowControllerMappingChanged = { showControllerMapping = it }, hostInputAuthorityEnabled = hostInputAuthorityEnabled, networkMode = networkMode, onNetworkModeChanged = onNetworkModeChanged, @@ -253,6 +317,11 @@ fun NetplayScreen( notAllPlayersHaveGame.collect { showNotAllPlayersHaveGame = true } } + var showDualCoreWarning by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + dualCoreWarning.collect { showDualCoreWarning = true } + } + var dismissSaveTransferProgressDialog by rememberSaveable { mutableStateOf(false) } if (saveTransferProgress == null) { dismissSaveTransferProgressDialog = false @@ -300,7 +369,10 @@ fun NetplayScreen( gameDigestProgress != null && !dismissGameDigestDialog -> { GameDigestProgressDialog( gameDigestProgress = gameDigestProgress, - onDismiss = { dismissGameDigestDialog = true }, + onDismiss = { + dismissGameDigestDialog = true + if (isHosting) onAbortGameDigest() + }, ) } @@ -324,6 +396,21 @@ fun NetplayScreen( onDismissRequest = { showNotAllPlayersHaveGame = false }, ) } + + showDualCoreWarning -> { + DualCoreWarningDialog( + onSetDualCoreEnabled = onSetDualCoreEnabled, + onStartGame = { + showDualCoreWarning = false + onConfirmStartGame() + }, + onSkipWarning = { + showDualCoreWarning = false + onSkipDualCoreWarning() + }, + onDismiss = { showDualCoreWarning = false }, + ) + } } } } @@ -341,6 +428,11 @@ private fun PortraitContent( showGamePicker: Boolean, onShowGamePickerChanged: (Boolean) -> Unit, players: List<Player>, + controllerMapping: ControllerMapping, + onGamecubePortChanged: (port: Int, player: Player?) -> Unit, + onWiimotePortChanged: (port: Int, player: Player?) -> Unit, + showControllerMapping: Boolean, + onShowControllerMappingChanged: (Boolean) -> Unit, hostInputAuthorityEnabled: Boolean, networkMode: NetworkMode, onNetworkModeChanged: (NetworkMode) -> Unit, @@ -380,6 +472,11 @@ private fun PortraitContent( showGamePicker = showGamePicker, onShowGamePickerChanged = onShowGamePickerChanged, players = players, + controllerMapping = controllerMapping, + onGamecubePortChanged = onGamecubePortChanged, + onWiimotePortChanged = onWiimotePortChanged, + showControllerMapping = showControllerMapping, + onShowControllerMappingChanged = onShowControllerMappingChanged, hostInputAuthorityEnabled = hostInputAuthorityEnabled, networkMode = networkMode, onNetworkModeChanged = onNetworkModeChanged, @@ -414,6 +511,11 @@ private fun LandscapeContent( showGamePicker: Boolean, onShowGamePickerChanged: (Boolean) -> Unit, players: List<Player>, + controllerMapping: ControllerMapping, + onGamecubePortChanged: (port: Int, player: Player?) -> Unit, + onWiimotePortChanged: (port: Int, player: Player?) -> Unit, + showControllerMapping: Boolean, + onShowControllerMappingChanged: (Boolean) -> Unit, hostInputAuthorityEnabled: Boolean, networkMode: NetworkMode, onNetworkModeChanged: (NetworkMode) -> Unit, @@ -446,7 +548,6 @@ private fun LandscapeContent( .fillMaxHeight() ) - val scrollState = rememberScrollState() Column( modifier = Modifier .weight(1f) @@ -465,6 +566,11 @@ private fun LandscapeContent( showGamePicker = showGamePicker, onShowGamePickerChanged = onShowGamePickerChanged, players = players, + controllerMapping = controllerMapping, + onGamecubePortChanged = onGamecubePortChanged, + onWiimotePortChanged = onWiimotePortChanged, + showControllerMapping = showControllerMapping, + onShowControllerMappingChanged = onShowControllerMappingChanged, hostInputAuthorityEnabled = hostInputAuthorityEnabled, networkMode = networkMode, onNetworkModeChanged = onNetworkModeChanged, @@ -486,6 +592,7 @@ private fun LandscapeContent( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PlayersAndSettings( game: String, @@ -494,6 +601,11 @@ private fun PlayersAndSettings( showGamePicker: Boolean, onShowGamePickerChanged: (Boolean) -> Unit, players: List<Player>, + controllerMapping: ControllerMapping, + onGamecubePortChanged: (port: Int, player: Player?) -> Unit, + onWiimotePortChanged: (port: Int, player: Player?) -> Unit, + showControllerMapping: Boolean, + onShowControllerMappingChanged: (Boolean) -> Unit, hostInputAuthorityEnabled: Boolean, networkMode: NetworkMode, onNetworkModeChanged: (NetworkMode) -> Unit, @@ -531,25 +643,15 @@ private fun PlayersAndSettings( MenuSpacer() - OutlinedBox( - label = { Text(stringResource(R.string.netplay_players_label)) }, - ) { - PlayersTable( - rows = buildList { - add( - listOf( - stringResource(R.string.netplay_players_name), - stringResource(R.string.netplay_players_ping), - stringResource(R.string.netplay_players_mapping), - ) - ) - addAll(players.map { listOf(it.name, it.ping.toString(), it.mapping) }) - repeat(4 - players.size) { add(listOf("", "", "")) } - }, - modifier = Modifier - .fillMaxWidth() - ) - } + Players( + players = players, + isHosting = isHosting, + controllerMapping = controllerMapping, + onGamecubePortChanged = onGamecubePortChanged, + onWiimotePortChanged = onWiimotePortChanged, + showControllerMapping = showControllerMapping, + onShowControllerMappingChanged = onShowControllerMappingChanged, + ) if (isHosting) { MenuSpacer() @@ -934,6 +1036,64 @@ private fun AddressRow( ) } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun Players( + players: List<Player>, + isHosting: Boolean, + controllerMapping: ControllerMapping, + onGamecubePortChanged: (Int, Player?) -> Unit, + onWiimotePortChanged: (Int, Player?) -> Unit, + showControllerMapping: Boolean, + onShowControllerMappingChanged: (Boolean) -> Unit, +) { + val sheetState = rememberSheetState( + skipPartiallyExpanded = true, + initialValue = if (showControllerMapping) SheetValue.Expanded else SheetValue.Hidden, + ) + + if (showControllerMapping) { + ModalBottomSheet( + onDismissRequest = { onShowControllerMappingChanged(false) }, + sheetState = sheetState, + modifier = Modifier + .statusBarsPadding() + ) { + ControllerMapping( + mapping = controllerMapping, + players = players, + onGamecubePortChanged = onGamecubePortChanged, + onWiimotePortChanged = onWiimotePortChanged, + ) + } + } + + OutlinedBox( + label = { Text(stringResource(R.string.netplay_players_label)) }, + onClick = if (isHosting) { + { onShowControllerMappingChanged(true) } + } else { + null + }, + ) { + PlayersTable( + rows = buildList { + add( + listOf( + stringResource(R.string.netplay_players_name), + stringResource(R.string.netplay_players_ping), + stringResource(R.string.netplay_players_mapping), + ) + ) + addAll(players.map { listOf(it.name, it.ping.toString(), it.mapping) }) + repeat(4 - players.size) { add(listOf("", "", "")) } + }, + modifier = Modifier + .fillMaxWidth() + ) + } +} + /** * A table arranged into columns sized to wrap the largest item. Except the * first column which takes up the remaining space left by the other columns. @@ -946,7 +1106,6 @@ private fun PlayersTable( ) { rows.zipWithNext { a, b -> if (a.size != b.size) throw IllegalArgumentException("Rows must all contain the same number of elements.") } val maxWidths = remember { List(rows.first().size) { mutableIntStateOf(0) } } - val density = LocalDensity.current Column( verticalArrangement = Arrangement.spacedBy(6.dp), @@ -958,23 +1117,21 @@ private fun PlayersTable( ) { row.forEachIndexed { itemIndex, text -> Box( - modifier = Modifier - .then( - when { - itemIndex == 0 -> Modifier.weight(1f) - - maxWidths[itemIndex].intValue > 0 -> Modifier - .width(with(density) { maxWidths[itemIndex].intValue.toDp() }) - - else -> Modifier + modifier = if (itemIndex == 0) { + Modifier.weight(1f) + } else { + val maxWidth = maxWidths[itemIndex] + Modifier.layout { measurable, constraints -> + val placeable = + measurable.measure(constraints.copy(maxWidth = Constraints.Infinity)) + if (placeable.width > maxWidth.intValue) { + maxWidth.intValue = placeable.width } - ) - .onGloballyPositioned { coordinates -> - val width = coordinates.size.width - if (width > maxWidths[itemIndex].intValue) { - maxWidths[itemIndex].intValue = width + layout(maxWidth.intValue, placeable.height) { + placeable.place(x = 0, y = 0) } } + } ) { Text( text = text, @@ -991,6 +1148,150 @@ private fun PlayersTable( } } +private val PORT_DROPDOWN_MIN_WIDTH = 140.dp +private val PORT_DROPDOWN_SPACING = 10.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ControllerMapping( + mapping: ControllerMapping, + players: List<Player>, + onGamecubePortChanged: (port: Int, player: Player?) -> Unit, + onWiimotePortChanged: (port: Int, player: Player?) -> Unit, +) { + BoxWithConstraints( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = DolphinTheme.scaffoldPadding) + .padding(bottom = 24.dp) + .navigationBarsPadding() + ) { + val columns = + ((maxWidth + PORT_DROPDOWN_SPACING) / (PORT_DROPDOWN_MIN_WIDTH + PORT_DROPDOWN_SPACING)) + .toInt() + .coerceAtLeast(1) + .let { if (it == 3) 2 else it } // Three per row would look strange so bump down to two. + + Column( + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + PortsSection( + titleId = R.string.netplay_controllers_gamecube, + portLabelId = R.string.netplay_controllers_port, + mapping = mapping.gamecubePorts, + players = players, + onPortChanged = onGamecubePortChanged, + columns = columns, + ) + + HorizontalDivider() + + PortsSection( + titleId = R.string.netplay_controllers_wii_remotes, + portLabelId = R.string.netplay_controllers_remote, + mapping = mapping.wiiRemotes, + players = players, + onPortChanged = onWiimotePortChanged, + columns = columns, + ) + } + } +} + +@Composable +private fun PortsSection( + @StringRes titleId: Int, + @StringRes portLabelId: Int, + mapping: List<Player?>, + players: List<Player>, + onPortChanged: (port: Int, player: Player?) -> Unit, + columns: Int, +) { + Column( + verticalArrangement = Arrangement.spacedBy(PORT_DROPDOWN_SPACING), + ) { + Text( + text = stringResource(titleId), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + mapping.indices.chunked(columns).forEach { row -> + Row( + horizontalArrangement = Arrangement.spacedBy(PORT_DROPDOWN_SPACING), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + ) { + row.forEach { port -> + PortDropdown( + label = stringResource(portLabelId, port + 1), + selectedPlayer = mapping[port], + players = players, + onPlayerSelected = { onPortChanged(port, it) }, + modifier = Modifier + .weight(1f) + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PortDropdown( + label: String, + selectedPlayer: Player?, + players: List<Player>, + onPlayerSelected: (player: Player?) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + fun Player.displayName() = "$name ($pid)" + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier, + ) { + OutlinedTextField( + value = selectedPlayer?.displayName() + ?: stringResource(R.string.netplay_controllers_none), + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.netplay_controllers_none)) }, + onClick = { + onPlayerSelected(null) + expanded = false + }, + ) + players.forEach { player -> + DropdownMenuItem( + text = { Text(player.displayName()) }, + onClick = { + onPlayerSelected(player) + expanded = false + }, + ) + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun NetworkModeDropdown( @@ -1217,11 +1518,12 @@ private fun GameDigestProgressDialog( HorizontalDivider() } } - if (gameDigestProgress.matches != null) { + val matches = gameDigestProgress.matches + if (matches != null) { Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource( - if (gameDigestProgress.matches) { + if (matches) { R.string.netplay_game_digest_match } else { R.string.netplay_game_digest_mismatch @@ -1234,7 +1536,7 @@ private fun GameDigestProgressDialog( } }, confirmButton = { - if (gameDigestProgress.matches != null) { + if (gameDigestProgress.finished) { TextButton(onClick = onDismiss) { Text(stringResource(R.string.netplay_game_digest_close)) } @@ -1276,6 +1578,73 @@ private fun GameDigestPlayerRow( } @Composable +private fun DualCoreWarningDialog( + onSetDualCoreEnabled: (Boolean) -> Unit, + onStartGame: () -> Unit, + onSkipWarning: () -> Unit, + onDismiss: () -> Unit, +) { + var dualCoreEnabled by rememberSaveable { mutableStateOf(true) } + AlertDialog( + title = { Text(stringResource(R.string.netplay_dual_core_warning_title)) }, + text = { + Column { + Text( + text = stringResource(R.string.netplay_dual_core_warning_message), + ) + val ripplePadding = 12.dp + Spacer(Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .layout { measurable, constraints -> + // Enlarge the ripple area beyond the dialog's padding + val hPx = ripplePadding.roundToPx() + val placeable = measurable.measure( + constraints.copy( + minWidth = constraints.maxWidth + hPx * 2, + maxWidth = constraints.maxWidth + hPx * 2 + ) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.place(-hPx, 0) + } + } + .clip(MaterialTheme.shapes.small) + .clickable { + dualCoreEnabled = !dualCoreEnabled + onSetDualCoreEnabled(dualCoreEnabled) + } + .padding(ripplePadding), + ) { + Text( + text = stringResource(R.string.dual_core), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Switch( + checked = dualCoreEnabled, + onCheckedChange = null, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onStartGame) { + Text(stringResource(R.string.netplay_start)) + } + }, + dismissButton = { + TextButton(onClick = onSkipWarning) { + Text(stringResource(R.string.netplay_dont_warn_again)) + } + }, + onDismissRequest = onDismiss, + ) +} + +@Composable private fun NetplayMessage.color(): Color { val isDark = isSystemInDarkTheme() return when (this) { @@ -1363,6 +1732,9 @@ private fun PreviewNetplayScreen() { onGameSelected = {}, gameFiles = emptyList(), notAllPlayersHaveGame = emptyFlow(), + dualCoreWarning = emptyFlow(), + onSetDualCoreEnabled = {}, + onSkipDualCoreWarning = {}, onConfirmStartGame = {}, hostInputAuthorityEnabled = true, networkMode = NetworkMode.HOST_INPUT_AUTHORITY, @@ -1377,6 +1749,9 @@ private fun PreviewNetplayScreen() { JoinInfoType.EXTERNAL to JoinAddress.Loaded("203.0.113.1:2626"), JoinInfoType.LOCAL to JoinAddress.Loaded("192.168.1.5:2626"), ), + controllerMapping = ControllerMapping.emptyControllerMapping(), + onGamecubePortChanged = { _, _ -> }, + onWiiRemoteChanged = { _, _ -> }, // saveTransferProgress = SaveTransferProgress( // title = "Title", // totalSize = 1024L, @@ -1393,5 +1768,8 @@ private fun PreviewNetplayScreen() { // ), // ), // ), + onAbortGameDigest = {}, + onComputeGameDigest = {}, + onComputeSDCardDigest = {}, ) } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt index ae222e8722..8b532b22de 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt @@ -1,9 +1,14 @@ // SPDX-License-Identifier: GPL-2.0-or-later -@file:OptIn(ExperimentalMaterial3Api::class) +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) package org.dolphinemu.dolphinemu.features.netplay.ui +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -19,6 +24,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator @@ -42,13 +48,22 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow @@ -58,6 +73,7 @@ import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType import org.dolphinemu.dolphinemu.ui.theme.DolphinScaffold import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer +import org.dolphinemu.dolphinemu.ui.theme.OutlinedBox private data class ErrorDialogState(val message: String) { val onDismissed = CompletableDeferred<Unit>() @@ -87,6 +103,23 @@ fun NetplaySetupScreen( onHostClicked: () -> Unit, onConnectClicked: () -> Unit, ) { + val localNetworkPermissionState: PermissionState? = + if ( + connectionType is ConnectionType.DirectConnection && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN + ) { + rememberPermissionState(Manifest.permission.ACCESS_LOCAL_NETWORK) { _ -> + // Try to connect even if the user denied the permission. The warning is on screen + // so it's up to them to determine if they are using local network and need to grant it. + when (connectionRole) { + ConnectionRole.Host -> onHostClicked() + ConnectionRole.Connect -> onConnectClicked() + } + } + } else { + null + } + DolphinScaffold( title = { Text(stringResource(R.string.netplay_setup_title)) }, navigationIcon = { @@ -99,9 +132,19 @@ fun NetplaySetupScreen( }, floatingActionButton = { ExtendedFloatingActionButton( - onClick = when(connectionRole) { - ConnectionRole.Host -> onHostClicked - ConnectionRole.Connect -> onConnectClicked + onClick = { + if ( + localNetworkPermissionState != null && + !localNetworkPermissionState.status.isGranted && + !localNetworkPermissionState.status.shouldShowRationale + ) { + localNetworkPermissionState.launchPermissionRequest() + } else { + when (connectionRole) { + ConnectionRole.Host -> onHostClicked() + ConnectionRole.Connect -> onConnectClicked() + } + } }, ) { if (connecting) { @@ -238,6 +281,10 @@ private fun NetplaySetupContent( onUseUpnpChanged = onUseUpnpChanged, ) } + + LocalNetworkPermissionInfo( + connectionType = connectionType, + ) } @Composable @@ -374,8 +421,87 @@ private fun HostMenu( ) } } + } +} + +@Composable +private fun LocalNetworkPermissionInfo( + connectionType: ConnectionType, +) { + if ( + connectionType is ConnectionType.DirectConnection && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN + ) { + val context = LocalContext.current + + // Android permissions do not differentiate between isGranted being false because the user + // has never been asked and isGranted being false because the user denied twice. We only + // get shouldShowRationale which, if the user keeps denying goes false, true, always false. + // So if the value does not change after calling launchPermissionRequest we know we are in + // the final denied state and launch app settings for the user to manually grant from there. + var previousShouldShowRationale by rememberSaveable { mutableStateOf(false) } - MenuSpacer() + // Check if the permission dialog actually showed by detecting an onPause event, this allows + // us to detect if the dialog was dismissed with the back button which would prevent + // previousShouldShowRationale from toggling as described above. + var dialogShown by rememberSaveable { mutableStateOf(false) } + LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { dialogShown = true } + + lateinit var permissionState: PermissionState + permissionState = + rememberPermissionState(Manifest.permission.ACCESS_LOCAL_NETWORK) { granted -> + if ( + !granted && + !dialogShown && + previousShouldShowRationale == permissionState.status.shouldShowRationale + ) { + context.startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null) + ) + ) + } + previousShouldShowRationale = permissionState.status.shouldShowRationale + } + + if (!permissionState.status.isGranted) { + MenuSpacer() + + OutlinedBox( + label = { + Icon( + imageVector = Icons.Default.Warning, + tint = MaterialTheme.colorScheme.error, + contentDescription = null, + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = stringResource(R.string.netplay_local_network_permission_info), + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = { + previousShouldShowRationale = permissionState.status.shouldShowRationale + dialogShown = false + permissionState.launchPermissionRequest() + }, + modifier = Modifier.align(Alignment.End) + ) { + Text(text = stringResource(R.string.netplay_give_permission)) + } + } + } + } } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt index 68c859082c..d3d0fb3339 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt @@ -947,7 +947,14 @@ enum class BooleanSetting( "ProgressEnabled", false ), - NETPLAY_USE_UPNP(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "UseUPNP", false); + ACHIEVEMENTS_SOUND_ENABLED( + Settings.FILE_ACHIEVEMENTS, + Settings.SECTION_ACHIEVEMENTS, + "SoundFeedback", + true + ), + NETPLAY_USE_UPNP(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "UseUPNP", false), + NETPLAY_SKIP_DUAL_CORE_WARNING(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "SkipDualCoreWarning", false); override val isOverridden: Boolean get() = NativeConfig.isOverridden(file, section, key) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/view/SettingsItem.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/view/SettingsItem.kt index 4d41676a14..20cfdcb2d0 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/view/SettingsItem.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/view/SettingsItem.kt @@ -86,5 +86,6 @@ abstract class SettingsItem { const val TYPE_STRING = 12 const val TYPE_HYPERLINK_HEADER = 13 const val TYPE_DATETIME_CHOICE = 14 + const val TYPE_SEARCH_RESULT = 15 } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/view/SettingsSearchResult.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/view/SettingsSearchResult.kt new file mode 100644 index 0000000000..dc786851ac --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/view/SettingsSearchResult.kt @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.settings.model.view + +import android.os.Bundle +import org.dolphinemu.dolphinemu.features.settings.model.AbstractSetting +import org.dolphinemu.dolphinemu.features.settings.ui.MenuTag + +class SettingsSearchResult( + name: CharSequence, + description: CharSequence, + val menuKey: MenuTag, + val settingPosition: Int, + val navigationExtras: Bundle? +) : SettingsItem(name, description) { + override val type: Int = TYPE_SEARCH_RESULT + + override val setting: AbstractSetting? = null +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/MenuTag.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/MenuTag.kt index 74556b07d9..3a6089144f 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/MenuTag.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/MenuTag.kt @@ -26,6 +26,7 @@ enum class MenuTag { STATISTICS("statistics"), ADVANCED_GRAPHICS("advanced_graphics"), GCPAD_TYPE("gc_pad_type"), + HOTKEYS("hotkeys"), WIIMOTE("wiimote"), WIIMOTE_EXTENSION("wiimote_extension"), GCPAD_1("gcpad", 0), @@ -77,7 +78,8 @@ enum class MenuTag { val correspondingEmulatedController: EmulatedController get() = if (isGCPadMenu) EmulatedController.getGcPad(subType) else if (isWiimoteMenu) EmulatedController.getWiimote( subType - ) else throw UnsupportedOperationException() + ) else if (this == HOTKEYS) EmulatedController.getHotkeys() + else throw UnsupportedOperationException() val isSerialPort1Menu: Boolean get() = this == CONFIG_SERIALPORT1 diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.kt index 68f957a626..19c08b9a05 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.kt @@ -7,14 +7,17 @@ import android.content.DialogInterface import android.content.Intent import android.os.Bundle import android.view.KeyEvent -import android.view.Menu import android.view.MotionEvent import android.view.View +import android.view.animation.PathInterpolator import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SearchView import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider @@ -39,6 +42,17 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView, ThemeProvide private var dialog: AlertDialog? = null private var toolbarLayout: CollapsingToolbarLayout? = null private var binding: ActivitySettingsBinding? = null + private lateinit var searchView: SearchView + private var expandedToolbarHeight = 0 + private var toolbarStateGeneration = 0 + private var currentToolbarTitle: String? = null + private var currentToolbarShowsHeadline = false + private var currentToolbarShowsSearch = false + private var currentToolbarShowsSearchMode = false + override val settingsSearchQuery: String + get() = presenter!!.settingsSearchQuery + override val isSettingsSearchActive: Boolean + get() = presenter!!.isSettingsSearchActive override var themeId: Int = 0 override var isMappingAllDevices = false @@ -76,8 +90,11 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView, ThemeProvide presenter = SettingsActivityPresenter(this, settings) presenter!!.onCreate(savedInstanceState, menuTag, gameID, revision, isWii, this) toolbarLayout = binding!!.toolbarSettingsLayout + expandedToolbarHeight = toolbarLayout!!.layoutParams.height setSupportActionBar(binding!!.toolbarSettings) supportActionBar!!.setDisplayHomeAsUpEnabled(true) + setUpSettingsSearch() + setUpBackNavigation() // TODO: Remove this when CollapsingToolbarLayouts are fixed by Google // https://github.com/material-components/material-components-android/issues/1310 @@ -86,16 +103,78 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView, ThemeProvide enableScrollTint(this, binding!!.toolbarSettings, binding!!.appbarSettings) } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - val inflater = menuInflater - inflater.inflate(R.menu.menu_settings, menu) - return true + private fun setUpSettingsSearch() { + searchView = binding!!.settingsSearch + searchView.setQuery(settingsSearchQuery, false) + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + searchView.clearFocus() + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + presenter!!.onSettingsSearchQueryChanged(newText.orEmpty()) + return true + } + }) + binding!!.settingsSearchPreview.setOnClickListener { enterSettingsSearch() } + binding!!.settingsSearchToolbar.setNavigationOnClickListener { exitSettingsSearch() } + } + + private fun enterSettingsSearch() { + if (!presenter!!.enterSettingsSearch()) { + return + } + + refreshToolbarState() + val focusDelay = if (areSystemAnimationsEnabled()) SEARCH_FOCUS_DELAY_MS else 0L + searchView.postDelayed({ + if (!isSettingsSearchActive) { + return@postDelayed + } + searchView.requestFocus() + WindowCompat.getInsetsController(window, searchView).show(WindowInsetsCompat.Type.ime()) + }, focusDelay) + } + + private fun exitSettingsSearch() { + if (!presenter!!.exitSettingsSearch()) { + return + } + + searchView.setQuery("", false) + searchView.clearFocus() + WindowCompat.getInsetsController(window, searchView).hide(WindowInsetsCompat.Type.ime()) + refreshToolbarState() + } + + private fun refreshToolbarState() { + val title = currentToolbarTitle ?: getString(R.string.settings) + setToolbarState( + title, currentToolbarShowsHeadline, currentToolbarShowsSearch + ) + } + + private fun setUpBackNavigation() { + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (supportFragmentManager.backStackEntryCount == 0 && isSettingsSearchActive) { + exitSettingsSearch() + return + } + + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } + }) } override fun onSaveInstanceState(outState: Bundle) { // Critical: If super method is not called, rotations will be busted. super.onSaveInstanceState(outState) outState.putBoolean(KEY_MAPPING_ALL_DEVICES, isMappingAllDevices) + presenter!!.onSaveInstanceState(outState) } override fun onStart() { @@ -128,10 +207,17 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView, ThemeProvide } override fun showSettingsFragment( + menuTag: MenuTag, extras: Bundle?, addToStack: Boolean, gameId: String + ) { + replaceSettingsFragment(menuTag, extras, addToStack, gameId, false) + } + + private fun replaceSettingsFragment( menuTag: MenuTag, extras: Bundle?, addToStack: Boolean, - gameId: String + gameId: String, + isSearchResult: Boolean ) { if (!addToStack && fragment != null) return val transaction = supportFragmentManager.beginTransaction() @@ -140,15 +226,18 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView, ThemeProvide transaction.setCustomAnimations( R.anim.anim_settings_fragment_in, R.anim.anim_settings_fragment_out, - 0, - R.anim.anim_pop_settings_fragment_out + if (isSearchResult) R.anim.anim_settings_search_pop_in else 0, + if (isSearchResult) { + R.anim.anim_settings_search_pop_out + } else { + R.anim.anim_pop_settings_fragment_out + } ) } transaction.addToBackStack(null) } transaction.replace( - R.id.frame_content_settings, - newInstance(menuTag, gameId, extras), FRAGMENT_TAG + R.id.frame_content_settings, newInstance(menuTag, gameId, extras), FRAGMENT_TAG ) transaction.commit() } @@ -157,16 +246,22 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView, ThemeProvide fragment.show(supportFragmentManager, FRAGMENT_DIALOG_TAG) } + override fun showSearchResult( + menuTag: MenuTag, settingPosition: Int, gameId: String, extras: Bundle? + ) { + val navigationExtras = extras?.let(::Bundle) ?: Bundle() + navigationExtras.putInt( + SettingsFragment.ARGUMENT_SCROLL_TO_SETTING_POSITION, settingPosition + ) + replaceSettingsFragment(menuTag, navigationExtras, true, gameId, true) + } + private fun areSystemAnimationsEnabled(): Boolean { val duration = android.provider.Settings.Global.getFloat( - contentResolver, - android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, - 1f + contentResolver, android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f ) val transition = android.provider.Settings.Global.getFloat( - contentResolver, - android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE, - 1f + contentResolver, android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE, 1f ) return duration != 0f && transition != 0f } @@ -183,10 +278,8 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView, ThemeProvide override fun showLoading() { if (dialog == null) { - dialog = MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.load_settings)) - .setView(R.layout.dialog_indeterminate_progress) - .create() + dialog = MaterialAlertDialogBuilder(this).setTitle(getString(R.string.load_settings)) + .setView(R.layout.dialog_indeterminate_progress).create() } dialog!!.show() } @@ -196,12 +289,10 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView, ThemeProvide } override fun showGameIniJunkDeletionQuestion() { - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.game_ini_junk_title)) + MaterialAlertDialogBuilder(this).setTitle(getString(R.string.game_ini_junk_title)) .setMessage(getString(R.string.game_ini_junk_question)) .setPositiveButton(R.string.yes) { _: DialogInterface?, _: Int -> presenter!!.clearGameSettings() } - .setNegativeButton(R.string.no, null) - .show() + .setNegativeButton(R.string.no, null).show() } override fun onSettingsFileLoaded(settings: Settings) { @@ -229,13 +320,78 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView, ThemeProvide return presenter!!.hasMenuTagActionForValue(menuTag, value) } + override fun getMenuTagActionExtras(menuTag: MenuTag, value: Int): Bundle? { + return presenter!!.getMenuTagActionExtras(menuTag, value) + } + + override fun filterSettings(query: String) { + fragment?.filterSettings(query) + } + override fun onSupportNavigateUp(): Boolean { - onBackPressed() + onBackPressedDispatcher.onBackPressed() return true } - override fun setToolbarTitle(title: String) { - binding!!.toolbarSettingsLayout.title = title + override fun setToolbarState(title: String, showHeadline: Boolean, showSearch: Boolean) { + val appBar = binding!!.appbarSettings + val generation = ++toolbarStateGeneration + val showSearchMode = showSearch && isSettingsSearchActive + val stateChanged = + currentToolbarTitle != title || currentToolbarShowsHeadline != showHeadline || currentToolbarShowsSearch != showSearch || currentToolbarShowsSearchMode != showSearchMode + appBar.animate().cancel() + + if (!appBar.isLaidOut || !stateChanged) { + applyToolbarState(title, showHeadline, showSearch) + appBar.alpha = 1f + return + } + + if (!showSearch) { + searchView.clearFocus() + } + + appBar.animate().alpha(0f).setDuration(APP_BAR_FADE_OUT_DURATION_MS) + .setInterpolator(APP_BAR_FADE_OUT_INTERPOLATOR).withEndAction { + if (generation != toolbarStateGeneration) { + return@withEndAction + } + + applyToolbarState(title, showHeadline, showSearch) + appBar.post { + if (generation != toolbarStateGeneration) { + return@post + } + + appBar.animate().alpha(1f).setDuration(APP_BAR_FADE_IN_DURATION_MS) + .setInterpolator(APP_BAR_FADE_IN_INTERPOLATOR).start() + } + }.start() + } + + private fun applyToolbarState(title: String, showHeadline: Boolean, showSearch: Boolean) { + val showSearchMode = showSearch && isSettingsSearchActive + toolbarLayout!!.isTitleEnabled = showHeadline + supportActionBar!!.title = title + if (showHeadline) { + toolbarLayout!!.title = title + } + toolbarLayout!!.layoutParams = toolbarLayout!!.layoutParams.apply { + height = if (showHeadline) { + expandedToolbarHeight + } else { + binding!!.toolbarSettings.layoutParams.height + } + } + toolbarLayout!!.visibility = if (showSearchMode) View.GONE else View.VISIBLE + binding!!.settingsSearchContainer.visibility = + if (showSearch && !showSearchMode) View.VISIBLE else View.GONE + binding!!.settingsSearchModeContainer.visibility = + if (showSearchMode) View.VISIBLE else View.GONE + currentToolbarTitle = title + currentToolbarShowsHeadline = showHeadline + currentToolbarShowsSearch = showSearch + currentToolbarShowsSearchMode = showSearchMode } override fun setOldControllerSettingsWarningVisibility(visible: Boolean): Int { @@ -274,14 +430,16 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView, ThemeProvide private const val KEY_MAPPING_ALL_DEVICES = "all_devices" private const val FRAGMENT_TAG = "settings" private const val FRAGMENT_DIALOG_TAG = "settings_dialog" + private const val APP_BAR_FADE_OUT_DURATION_MS = 90L + private const val APP_BAR_FADE_IN_DURATION_MS = 180L + private const val SEARCH_FOCUS_DELAY_MS = + APP_BAR_FADE_OUT_DURATION_MS + APP_BAR_FADE_IN_DURATION_MS + private val APP_BAR_FADE_OUT_INTERPOLATOR = PathInterpolator(0.4f, 0f, 1f, 1f) + private val APP_BAR_FADE_IN_INTERPOLATOR = PathInterpolator(0f, 0f, 0.2f, 1f) @JvmStatic fun launch( - context: Context, - menuTag: MenuTag?, - gameId: String?, - revision: Int, - isWii: Boolean + context: Context, menuTag: MenuTag?, gameId: String?, revision: Int, isWii: Boolean ) { val settings = Intent(context, SettingsActivity::class.java) settings.putExtra(ARG_MENU_TAG, menuTag) @@ -296,8 +454,7 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView, ThemeProvide val settings = Intent(context, SettingsActivity::class.java) settings.putExtra(ARG_MENU_TAG, menuTag) settings.putExtra( - ARG_IS_WII, - !NativeLibrary.IsRunning() || NativeLibrary.IsEmulatingWii() + ARG_IS_WII, !NativeLibrary.IsRunning() || NativeLibrary.IsEmulatingWii() ) context.startActivity(settings) } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityPresenter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityPresenter.kt index 7627a7b5c6..5baee17f02 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityPresenter.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityPresenter.kt @@ -10,14 +10,17 @@ import org.dolphinemu.dolphinemu.utils.AfterDirectoryInitializationRunner import org.dolphinemu.dolphinemu.utils.Log class SettingsActivityPresenter( - private val activityView: SettingsActivityView, - var settings: Settings? + private val activityView: SettingsActivityView, var settings: Settings? ) { private var menuTag: MenuTag? = null private var gameId: String? = null private var revision = 0 private var isWii = false private lateinit var activity: AppCompatActivity + var settingsSearchQuery = "" + private set + var isSettingsSearchActive = false + private set fun onCreate( savedInstanceState: Bundle?, @@ -32,6 +35,43 @@ class SettingsActivityPresenter( this.revision = revision this.isWii = isWii this.activity = activity + if (savedInstanceState != null) { + isSettingsSearchActive = + savedInstanceState.getBoolean(KEY_SETTINGS_SEARCH_ACTIVE) + settingsSearchQuery = + savedInstanceState.getString(KEY_SETTINGS_SEARCH_QUERY).orEmpty() + } + } + + fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(KEY_SETTINGS_SEARCH_ACTIVE, isSettingsSearchActive) + outState.putString(KEY_SETTINGS_SEARCH_QUERY, settingsSearchQuery) + } + + fun onSettingsSearchQueryChanged(query: String) { + settingsSearchQuery = query + activityView.filterSettings(query) + } + + fun enterSettingsSearch(): Boolean { + if (isSettingsSearchActive) { + return false + } + + isSettingsSearchActive = true + activityView.filterSettings(settingsSearchQuery) + return true + } + + fun exitSettingsSearch(): Boolean { + if (!isSettingsSearchActive) { + return false + } + + isSettingsSearchActive = false + settingsSearchQuery = "" + activityView.filterSettings("") + return true } fun onDestroy() { @@ -85,55 +125,49 @@ class SettingsActivityPresenter( } fun onMenuTagAction(menuTag: MenuTag, value: Int) { - if (menuTag.isSerialPort1Menu) { - // Not disabled or dummy - if (value != 0 && value != 255) { - val bundle = Bundle() - bundle.putInt(SettingsFragmentPresenter.ARG_SERIALPORT1_TYPE, value) - activityView.showSettingsFragment(menuTag, bundle, true, gameId!!) - } - } - if (menuTag.isGCPadMenu) { - // Not disabled - if (value != 0) - { - val bundle = Bundle() - bundle.putInt(SettingsFragmentPresenter.ARG_CONTROLLER_TYPE, value) - activityView.showSettingsFragment(menuTag, bundle, true, gameId!!) - } - } - if (menuTag.isWiimoteMenu) { - // Emulated Wii Remote - if (value == 1) { - activityView.showSettingsFragment(menuTag, null, true, gameId!!) - } - } - if (menuTag.isWiimoteExtensionMenu) { - // Not disabled - if (value != 0) { - val bundle = Bundle() - bundle.putInt(SettingsFragmentPresenter.ARG_CONTROLLER_TYPE, value) - activityView.showSettingsFragment(menuTag, bundle, true, gameId!!) - } - } + val action = getMenuTagAction(menuTag, value) ?: return + activityView.showSettingsFragment(action.menuTag, action.extras, true, gameId!!) } fun hasMenuTagActionForValue(menuTag: MenuTag, value: Int): Boolean { - if (menuTag.isSerialPort1Menu) { + return getMenuTagAction(menuTag, value) != null + } + + fun getMenuTagActionExtras(menuTag: MenuTag, value: Int): Bundle? { + return getMenuTagAction(menuTag, value)?.extras + } + + private fun getMenuTagAction(menuTag: MenuTag, value: Int): MenuTagAction? { + return when { // Not disabled or dummy - return value != 0 && value != 255 - } - if (menuTag.isGCPadMenu) { + menuTag.isSerialPort1Menu && value != 0 && value != 255 -> MenuTagAction( + menuTag, Bundle().apply { + putInt(SettingsFragmentPresenter.ARG_SERIALPORT1_TYPE, value) + }) + // Not disabled - return value != 0 - } - if (menuTag.isWiimoteMenu) { + menuTag.isGCPadMenu && value != 0 -> MenuTagAction( + menuTag, Bundle().apply { + putInt(SettingsFragmentPresenter.ARG_CONTROLLER_TYPE, value) + }) + // Emulated Wii Remote - return value == 1 - } - return if (menuTag.isWiimoteExtensionMenu) { + menuTag.isWiimoteMenu && value == 1 -> MenuTagAction(menuTag, null) + // Not disabled - value != 0 - } else false + menuTag.isWiimoteExtensionMenu && value != 0 -> MenuTagAction( + menuTag, Bundle().apply { + putInt(SettingsFragmentPresenter.ARG_CONTROLLER_TYPE, value) + }) + + else -> null + } + } + + private data class MenuTagAction(val menuTag: MenuTag, val extras: Bundle?) + + companion object { + private const val KEY_SETTINGS_SEARCH_ACTIVE = "settings_search_active" + private const val KEY_SETTINGS_SEARCH_QUERY = "settings_search_query" } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityView.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityView.kt index 1cd39f6fe4..95951f1ae9 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityView.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivityView.kt @@ -11,19 +11,36 @@ import org.dolphinemu.dolphinemu.features.settings.model.Settings */ interface SettingsActivityView { /** + * The query currently displayed in the settings search view. + */ + val settingsSearchQuery: String + + /** + * Whether the dedicated settings search screen is active. + */ + val isSettingsSearchActive: Boolean + + /** * Show a new SettingsFragment. * * @param menuTag Identifier for the settings group that should be displayed. * @param addToStack Whether or not this fragment should replace a previous one. */ fun showSettingsFragment( - menuTag: MenuTag, - extras: Bundle?, - addToStack: Boolean, - gameId: String + menuTag: MenuTag, extras: Bundle?, addToStack: Boolean, gameId: String ) /** + * Opens the settings screen containing a search result and scrolls to the result. + */ + fun showSearchResult(menuTag: MenuTag, settingPosition: Int, gameId: String, extras: Bundle?) + + /** + * Filters the root settings screen using the current search query. + */ + fun filterSettings(query: String) + + /** * Shows a DialogFragment. * * Only one can be shown at a time. @@ -87,6 +104,11 @@ interface SettingsActivityView { fun hasMenuTagActionForValue(menuTag: MenuTag, value: Int): Boolean /** + * Returns the arguments used when opening a navigable setting's associated screen. + */ + fun getMenuTagActionExtras(menuTag: MenuTag, value: Int): Bundle? + + /** * Show loading dialog while loading the settings */ fun showLoading() @@ -102,9 +124,9 @@ interface SettingsActivityView { fun showGameIniJunkDeletionQuestion() /** - * Accesses the material toolbar layout and changes the title + * Updates the settings app bar as a single state change. */ - fun setToolbarTitle(title: String) + fun setToolbarState(title: String, showHeadline: Boolean, showSearch: Boolean) /** * Returns whether the input mapping dialog should detect inputs from all devices, * not just the device configured for the controller. diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt index ae236d3764..6252350d32 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt @@ -27,14 +27,46 @@ import com.google.android.material.slider.Slider import com.google.android.material.timepicker.MaterialTimePicker import com.google.android.material.timepicker.TimeFormat import org.dolphinemu.dolphinemu.R -import org.dolphinemu.dolphinemu.databinding.* +import org.dolphinemu.dolphinemu.databinding.DialogAdvancedMappingBinding +import org.dolphinemu.dolphinemu.databinding.DialogInputStringBinding +import org.dolphinemu.dolphinemu.databinding.DialogSliderBinding +import org.dolphinemu.dolphinemu.databinding.ListItemHeaderBinding +import org.dolphinemu.dolphinemu.databinding.ListItemMappingBinding +import org.dolphinemu.dolphinemu.databinding.ListItemSearchResultBinding +import org.dolphinemu.dolphinemu.databinding.ListItemSettingBinding +import org.dolphinemu.dolphinemu.databinding.ListItemSettingSwitchBinding +import org.dolphinemu.dolphinemu.databinding.ListItemSubmenuBinding import org.dolphinemu.dolphinemu.features.input.model.view.InputMappingControlSetting import org.dolphinemu.dolphinemu.features.input.ui.AdvancedMappingDialog import org.dolphinemu.dolphinemu.features.input.ui.MotionAlertDialog import org.dolphinemu.dolphinemu.features.input.ui.viewholder.InputMappingControlSettingViewHolder import org.dolphinemu.dolphinemu.features.settings.model.Settings -import org.dolphinemu.dolphinemu.features.settings.model.view.* -import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.* +import org.dolphinemu.dolphinemu.features.settings.model.view.DateTimeChoiceSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.DirectoryPicker +import org.dolphinemu.dolphinemu.features.settings.model.view.FilePicker +import org.dolphinemu.dolphinemu.features.settings.model.view.FloatSliderSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.InputStringSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.IntSliderSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem +import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsSearchResult +import org.dolphinemu.dolphinemu.features.settings.model.view.SingleChoiceSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.SingleChoiceSettingDynamicDescriptions +import org.dolphinemu.dolphinemu.features.settings.model.view.SliderSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.StringSingleChoiceSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.SubmenuSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.SwitchSetting +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.DateTimeSettingViewHolder +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.FilePickerViewHolder +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.HeaderHyperLinkViewHolder +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.HeaderViewHolder +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.InputStringSettingViewHolder +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.RunRunnableViewHolder +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.SettingViewHolder +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.SettingsSearchResultViewHolder +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.SingleChoiceViewHolder +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.SliderViewHolder +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.SubmenuViewHolder +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.SwitchSettingViewHolder import org.dolphinemu.dolphinemu.utils.DirectoryInitialization import org.dolphinemu.dolphinemu.utils.FileBrowserHelper import org.dolphinemu.dolphinemu.utils.Log @@ -42,14 +74,13 @@ import org.dolphinemu.dolphinemu.utils.PermissionsHandler import java.io.File import java.io.IOException import java.io.RandomAccessFile -import java.util.* +import java.util.Calendar +import java.util.TimeZone import kotlin.math.roundToInt class SettingsAdapter( - private val fragmentView: SettingsFragmentView, - private val context: Context -) : - RecyclerView.Adapter<SettingViewHolder>(), DialogInterface.OnClickListener, + private val fragmentView: SettingsFragmentView, private val context: Context +) : RecyclerView.Adapter<SettingViewHolder>(), DialogInterface.OnClickListener, Slider.OnChangeListener { private var settingsList: ArrayList<SettingsItem>? = null private var clickedItem: SettingsItem? = null @@ -68,55 +99,59 @@ class SettingsAdapter( val inflater = LayoutInflater.from(parent.context) return when (viewType) { SettingsItem.TYPE_HEADER -> HeaderViewHolder( - ListItemHeaderBinding.inflate(inflater, parent, false), - this + ListItemHeaderBinding.inflate(inflater, parent, false), this ) + SettingsItem.TYPE_SWITCH -> SwitchSettingViewHolder( - ListItemSettingSwitchBinding.inflate(inflater, parent, false), - this + ListItemSettingSwitchBinding.inflate(inflater, parent, false), this ) - SettingsItem.TYPE_STRING_SINGLE_CHOICE, - SettingsItem.TYPE_SINGLE_CHOICE_DYNAMIC_DESCRIPTIONS, - SettingsItem.TYPE_SINGLE_CHOICE -> SingleChoiceViewHolder( - ListItemSettingBinding.inflate(inflater, parent, false), - this + + SettingsItem.TYPE_STRING_SINGLE_CHOICE, SettingsItem.TYPE_SINGLE_CHOICE_DYNAMIC_DESCRIPTIONS, SettingsItem.TYPE_SINGLE_CHOICE -> SingleChoiceViewHolder( + ListItemSettingBinding.inflate(inflater, parent, false), this ) + SettingsItem.TYPE_SLIDER -> SliderViewHolder( - ListItemSettingBinding.inflate(inflater, parent, false), - this, - context + ListItemSettingBinding.inflate(inflater, parent, false), this, context ) + SettingsItem.TYPE_SUBMENU -> SubmenuViewHolder( - ListItemSubmenuBinding.inflate(inflater, parent, false), - this + ListItemSubmenuBinding.inflate(inflater, parent, false), this ) + SettingsItem.TYPE_INPUT_MAPPING_CONTROL -> InputMappingControlSettingViewHolder( - ListItemMappingBinding.inflate(inflater, parent, false), - this + ListItemMappingBinding.inflate(inflater, parent, false), this ) - SettingsItem.TYPE_FILE_PICKER, - SettingsItem.TYPE_DIRECTORY_PICKER -> FilePickerViewHolder( - ListItemSettingBinding.inflate(inflater, parent, false), - this + + SettingsItem.TYPE_FILE_PICKER, SettingsItem.TYPE_DIRECTORY_PICKER -> FilePickerViewHolder( + ListItemSettingBinding.inflate(inflater, parent, false), this ) + SettingsItem.TYPE_RUN_RUNNABLE -> RunRunnableViewHolder( - ListItemSettingBinding.inflate(inflater, parent, false), - this, context + ListItemSettingBinding.inflate(inflater, parent, false), this, context ) + SettingsItem.TYPE_STRING -> InputStringSettingViewHolder( ListItemSettingBinding.inflate(inflater, parent, false), this ) + SettingsItem.TYPE_HYPERLINK_HEADER -> HeaderHyperLinkViewHolder( ListItemHeaderBinding.inflate(inflater, parent, false), this ) + SettingsItem.TYPE_DATETIME_CHOICE -> DateTimeSettingViewHolder( ListItemSettingBinding.inflate(inflater, parent, false), this ) + + SettingsItem.TYPE_SEARCH_RESULT -> SettingsSearchResultViewHolder( + ListItemSearchResultBinding.inflate(inflater, parent, false), this + ) + else -> throw IllegalArgumentException("Invalid view type: $viewType") } } override fun onBindViewHolder(holder: SettingViewHolder, position: Int) { + holder.clearSearchResultHighlight() holder.bind(getItem(position)) } @@ -143,7 +178,7 @@ class SettingsAdapter( fun clearSetting(item: SettingsItem) { item.clear(settings!!) - fragmentView.onSettingChanged() + fragmentView.onSettingChanged(item) } fun notifyAllSettingsChanged() { @@ -153,7 +188,7 @@ class SettingsAdapter( fun onBooleanClick(item: SwitchSetting, checked: Boolean) { item.setChecked(settings!!, checked) - fragmentView.onSettingChanged() + fragmentView.onSettingChanged(item) } fun onInputStringClick(item: InputStringSetting, position: Int) { @@ -161,29 +196,24 @@ class SettingsAdapter( val binding = DialogInputStringBinding.inflate(inflater) val input = binding.input input.setText(item.selectedValue) - dialog = MaterialAlertDialogBuilder(fragmentView.fragmentActivity) - .setView(binding.root) + dialog = MaterialAlertDialogBuilder(fragmentView.fragmentActivity).setView(binding.root) .setMessage(item.description) .setPositiveButton(R.string.ok) { _: DialogInterface?, _: Int -> val editTextInput = input.text.toString() if (item.selectedValue != editTextInput) { notifyItemChanged(position) - fragmentView.onSettingChanged() + fragmentView.onSettingChanged(item) } item.setSelectedValue(fragmentView.settings!!, editTextInput) - } - .setNegativeButton(R.string.cancel, null) - .show() + }.setNegativeButton(R.string.cancel, null).show() } fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) { clickedItem = item clickedPosition = position val value = getSelectionForSingleChoiceValue(item) - dialog = MaterialAlertDialogBuilder(fragmentView.fragmentActivity) - .setTitle(item.name) - .setSingleChoiceItems(item.choicesId, value, this) - .show() + dialog = MaterialAlertDialogBuilder(fragmentView.fragmentActivity).setTitle(item.name) + .setSingleChoiceItems(item.choicesId, value, this).show() } fun onStringSingleChoiceClick(item: StringSingleChoiceSetting, position: Int) { @@ -193,35 +223,26 @@ class SettingsAdapter( val choices = item.choices val noChoicesAvailableString = item.noChoicesAvailableString dialog = if (noChoicesAvailableString != 0 && choices.isEmpty()) { - MaterialAlertDialogBuilder(fragmentView.fragmentActivity) - .setTitle(item.name) - .setMessage(noChoicesAvailableString) - .setPositiveButton(R.string.ok, null) - .show() + MaterialAlertDialogBuilder(fragmentView.fragmentActivity).setTitle(item.name) + .setMessage(noChoicesAvailableString).setPositiveButton(R.string.ok, null).show() } else { - MaterialAlertDialogBuilder(fragmentView.fragmentActivity) - .setTitle(item.name) + MaterialAlertDialogBuilder(fragmentView.fragmentActivity).setTitle(item.name) .setSingleChoiceItems( - item.choices, item.selectedValueIndex, - this - ) - .show() + item.choices, item.selectedValueIndex, this + ).show() } } fun onSingleChoiceDynamicDescriptionsClick( - item: SingleChoiceSettingDynamicDescriptions, - position: Int + item: SingleChoiceSettingDynamicDescriptions, position: Int ) { clickedItem = item clickedPosition = position val value = getSelectionForSingleChoiceDynamicDescriptionsValue(item) - dialog = MaterialAlertDialogBuilder(fragmentView.fragmentActivity) - .setTitle(item.name) - .setSingleChoiceItems(item.choicesId, value, this) - .show() + dialog = MaterialAlertDialogBuilder(fragmentView.fragmentActivity).setTitle(item.name) + .setSingleChoiceItems(item.choicesId, value, this).show() } fun onSliderClick(item: SliderSetting, position: Int) { @@ -251,6 +272,7 @@ class SettingsAdapter( slider.valueTo = item.max slider.stepSize = item.stepSize } + is IntSliderSetting -> { slider.valueFrom = item.min.toFloat() slider.valueTo = item.max.toFloat() @@ -260,29 +282,27 @@ class SettingsAdapter( slider.value = (seekbarProgress / slider.stepSize).roundToInt() * slider.stepSize slider.addOnChangeListener(this) - dialog = MaterialAlertDialogBuilder(fragmentView.fragmentActivity) - .setTitle(item.name) - .setView(binding.root) - .setPositiveButton(R.string.ok, this) - .show() + dialog = MaterialAlertDialogBuilder(fragmentView.fragmentActivity).setTitle(item.name) + .setView(binding.root).setPositiveButton(R.string.ok, this).show() } fun onSubmenuClick(item: SubmenuSetting) { fragmentView.loadSubMenu(item.menuKey) } + fun onSearchResultClick(item: SettingsSearchResult) { + fragmentView.loadSearchResult(item.menuKey, item.settingPosition, item.navigationExtras) + } + fun onInputMappingClick(item: InputMappingControlSetting, position: Int) { if (item.controller.getDefaultDevice().isEmpty() && !fragmentView.isMappingAllDevices) { - MaterialAlertDialogBuilder(fragmentView.fragmentActivity) - .setMessage(R.string.input_binding_no_device) - .setPositiveButton(R.string.ok, this) - .show() + MaterialAlertDialogBuilder(fragmentView.fragmentActivity).setMessage(R.string.input_binding_no_device) + .setPositiveButton(R.string.ok, this).show() return } val dialog = MotionAlertDialog( - fragmentView.fragmentActivity, item, - fragmentView.isMappingAllDevices + fragmentView.fragmentActivity, item, fragmentView.isMappingAllDevices ) val background = ContextCompat.getDrawable(context, R.drawable.dialog_round) @@ -296,18 +316,16 @@ class SettingsAdapter( dialog.setTitle(R.string.input_binding) dialog.setMessage( String.format( - context.getString(R.string.input_binding_description), - item.name + context.getString(R.string.input_binding_description), item.name ) ) dialog.setButton(AlertDialog.BUTTON_NEGATIVE, context.getString(R.string.cancel), this) dialog.setButton( - AlertDialog.BUTTON_NEUTRAL, - context.getString(R.string.clear) + AlertDialog.BUTTON_NEUTRAL, context.getString(R.string.clear) ) { _: DialogInterface?, _: Int -> item.clearValue() } dialog.setOnDismissListener { notifyItemChanged(position) - fragmentView.onSettingChanged() + fragmentView.onSettingChanged(item) } dialog.setCanceledOnTouchOutside(false) dialog.show() @@ -317,10 +335,7 @@ class SettingsAdapter( val inflater = LayoutInflater.from(context) val binding = DialogAdvancedMappingBinding.inflate(inflater) val dialog = AdvancedMappingDialog( - context, - binding, - item.controlReference, - item.controller + context, binding, item.controlReference, item.controller ) val background = ContextCompat.getDrawable(context, R.drawable.dialog_round) @@ -338,12 +353,11 @@ class SettingsAdapter( ) { _: DialogInterface?, _: Int -> item.value = dialog.expression notifyItemChanged(position) - fragmentView.onSettingChanged() + fragmentView.onSettingChanged(item) } dialog.setButton(AlertDialog.BUTTON_NEGATIVE, context.getString(R.string.cancel), this) dialog.setButton( - AlertDialog.BUTTON_NEUTRAL, - context.getString(R.string.clear) + AlertDialog.BUTTON_NEUTRAL, context.getString(R.string.clear) ) { _: DialogInterface?, _: Int -> } dialog.setCanceledOnTouchOutside(false) dialog.show() @@ -361,14 +375,12 @@ class SettingsAdapter( val directoryPicker = item as DirectoryPicker if (!PermissionsHandler.isExternalStorageLegacy()) { - MaterialAlertDialogBuilder(context) - .setMessage(R.string.path_not_changeable_scoped_storage) + MaterialAlertDialogBuilder(context).setMessage(R.string.path_not_changeable_scoped_storage) .setPositiveButton(R.string.ok) { dialog: DialogInterface, _: Int -> dialog.dismiss() } .show() } else { val intent = FileBrowserHelper.createDirectoryPickerIntent( - fragmentView.fragmentActivity, - FileBrowserHelper.GAME_EXTENSIONS + fragmentView.fragmentActivity, FileBrowserHelper.GAME_EXTENSIONS ) directoryPicker.launcher.launch(intent) } @@ -400,32 +412,24 @@ class SettingsAdapter( calendar.timeZone = TimeZone.getTimeZone("UTC") // Start and end epoch times available for the Wii's date picker - val calendarConstraints = CalendarConstraints.Builder() - .setStart(946684800000L) - .setEnd(2082672000000L) - .build() + val calendarConstraints = + CalendarConstraints.Builder().setStart(946684800000L).setEnd(2082672000000L).build() var timeFormat = TimeFormat.CLOCK_12H if (DateFormat.is24HourFormat(fragmentView.fragmentActivity)) { timeFormat = TimeFormat.CLOCK_24H } - val datePicker = MaterialDatePicker.Builder.datePicker() - .setSelection(storedTime) - .setTitleText(R.string.select_rtc_date) - .setCalendarConstraints(calendarConstraints) - .build() - val timePicker = MaterialTimePicker.Builder() - .setTimeFormat(timeFormat) - .setHour(calendar[Calendar.HOUR_OF_DAY]) - .setMinute(calendar[Calendar.MINUTE]) - .setTitleText(R.string.select_rtc_time) + val datePicker = MaterialDatePicker.Builder.datePicker().setSelection(storedTime) + .setTitleText(R.string.select_rtc_date).setCalendarConstraints(calendarConstraints) .build() + val timePicker = MaterialTimePicker.Builder().setTimeFormat(timeFormat) + .setHour(calendar[Calendar.HOUR_OF_DAY]).setMinute(calendar[Calendar.MINUTE]) + .setTitleText(R.string.select_rtc_time).build() datePicker.addOnPositiveButtonClickListener { timePicker.show( - fragmentView.fragmentActivity.supportFragmentManager, - "TimePicker" + fragmentView.fragmentActivity.supportFragmentManager, "TimePicker" ) } timePicker.addOnPositiveButtonClickListener { @@ -435,7 +439,7 @@ class SettingsAdapter( val rtcString = "0x" + java.lang.Long.toHexString(epochTime) if (item.getSelectedValue() != rtcString) { notifyItemChanged(clickedPosition) - fragmentView.onSettingChanged() + fragmentView.onSettingChanged(item) } item.setSelectedValue(fragmentView.settings!!, rtcString) clickedItem = null @@ -448,7 +452,7 @@ class SettingsAdapter( if (filePicker.getSelectedValue() != selectedFile) { notifyItemChanged(clickedPosition) - fragmentView.onSettingChanged() + fragmentView.onSettingChanged(filePicker) } filePicker.setSelectedValue(fragmentView.settings!!, selectedFile) @@ -470,44 +474,50 @@ class SettingsAdapter( val scSetting = clickedItem as SingleChoiceSetting val value = getValueForSingleChoiceSelection(scSetting, which) - if (scSetting.selectedValue != value) fragmentView.onSettingChanged() + if (scSetting.selectedValue != value) fragmentView.onSettingChanged(scSetting) scSetting.setSelectedValue(settings!!, value) closeDialog() } + is SingleChoiceSettingDynamicDescriptions -> { val scSetting = clickedItem as SingleChoiceSettingDynamicDescriptions val value = getValueForSingleChoiceDynamicDescriptionsSelection(scSetting, which) - if (scSetting.selectedValue != value) fragmentView.onSettingChanged() + if (scSetting.selectedValue != value) fragmentView.onSettingChanged(scSetting) scSetting.setSelectedValue(settings!!, value) closeDialog() } + is StringSingleChoiceSetting -> { val scSetting = clickedItem as StringSingleChoiceSetting val value = scSetting.getValueAt(which) - if (scSetting.selectedValue != value) fragmentView.onSettingChanged() + if (scSetting.selectedValue != value) fragmentView.onSettingChanged(scSetting) scSetting.setSelectedValue(settings!!, value) closeDialog() } + is IntSliderSetting -> { val sliderSetting = clickedItem as IntSliderSetting if (sliderSetting.selectedValue != seekbarProgress.toInt()) { - fragmentView.onSettingChanged() + fragmentView.onSettingChanged(sliderSetting) } sliderSetting.setSelectedValue(settings!!, seekbarProgress.toInt()) closeDialog() } + is FloatSliderSetting -> { val sliderSetting = clickedItem as FloatSliderSetting - if (sliderSetting.selectedValue != seekbarProgress) fragmentView.onSettingChanged() + if (sliderSetting.selectedValue != seekbarProgress) { + fragmentView.onSettingChanged(sliderSetting) + } sliderSetting.setSelectedValue(settings!!, seekbarProgress) @@ -540,6 +550,7 @@ class SettingsAdapter( override fun onViewRecycled(holder: SettingViewHolder) { super.onViewRecycled(holder) + holder.clearSearchResultHighlight() holder.onViewRecycled() } @@ -587,8 +598,7 @@ class SettingsAdapter( } private fun getValueForSingleChoiceDynamicDescriptionsSelection( - item: SingleChoiceSettingDynamicDescriptions, - which: Int + item: SingleChoiceSettingDynamicDescriptions, which: Int ): Int { val valuesId = item.valuesId return if (valuesId > 0) { diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt index 1289d30f61..e0f96e77f1 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt @@ -24,17 +24,22 @@ import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.R import org.dolphinemu.dolphinemu.databinding.FragmentSettingsBinding import org.dolphinemu.dolphinemu.features.settings.model.Settings import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem +import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.SettingViewHolder import org.dolphinemu.dolphinemu.utils.GpuDriverInstallResult import org.dolphinemu.dolphinemu.utils.SerializableHelper.serializable -import java.util.* -import kotlin.collections.ArrayList +import java.util.EnumMap class SettingsFragment : Fragment(), SettingsFragmentView { private lateinit var presenter: SettingsFragmentPresenter @@ -51,6 +56,11 @@ class SettingsFragment : Fragment(), SettingsFragmentView { SettingsActivityResultLaunchers(this) { adapter } private var oldControllerSettingsWarningHeight = 0 + private var hasScrolledToSearchResult = false + private var highlightedSearchResult: SettingsItem? = null + private var highlightedSearchResultPosition = RecyclerView.NO_POSITION + private var searchIndexWarmupJob: Job? = null + private var searchJob: Job? = null private var binding: FragmentSettingsBinding? = null @@ -82,9 +92,7 @@ class SettingsFragment : Fragment(), SettingsFragmentView { } override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = FragmentSettingsBinding.inflate(inflater, container, false) return binding!!.root @@ -92,7 +100,11 @@ class SettingsFragment : Fragment(), SettingsFragmentView { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { if (titles.containsKey(menuTag)) { - activityView!!.setToolbarTitle(getString(titles[menuTag]!!)) + activityView!!.setToolbarState( + getString(titles[menuTag]!!), + menuTag != MenuTag.SETTINGS, + menuTag == MenuTag.SETTINGS + ) } val manager = LinearLayoutManager(activity) @@ -107,10 +119,13 @@ class SettingsFragment : Fragment(), SettingsFragmentView { setInsets() val activity = requireActivity() as SettingsActivityView + presenter.invalidateSearchIndex() presenter.onViewCreated(menuTag, activity.settings) } override fun onDestroyView() { + clearSearchResultHighlight() + searchJob?.cancel() super.onDestroyView() binding = null } @@ -129,7 +144,81 @@ class SettingsFragment : Fragment(), SettingsFragmentView { } override fun showSettingsList(settingsList: ArrayList<SettingsItem>) { - adapter!!.setSettings(settingsList) + val query = activityView?.settingsSearchQuery.orEmpty() + val isShowingSearch = + menuTag == MenuTag.SETTINGS && activityView?.isSettingsSearchActive == true + if (!isShowingSearch) { + adapter!!.setSettings(settingsList) + } + if (menuTag == MenuTag.SETTINGS) { + warmUpSearchIndex() + if (isShowingSearch) { + applySettingsFilter(query) + } + } + + val position = arguments?.getInt( + ARGUMENT_SCROLL_TO_SETTING_POSITION, RecyclerView.NO_POSITION + ) ?: RecyclerView.NO_POSITION + if (!hasScrolledToSearchResult && position in settingsList.indices) { + hasScrolledToSearchResult = true + binding?.listSettings?.post { + val recyclerView = binding?.listSettings ?: return@post + (recyclerView.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset( + position, 0 + ) + highlightSearchResult(position, settingsList[position]) + } + } + } + + fun filterSettings(query: String) { + if (!this::presenter.isInitialized || presenter.settings == null) { + return + } + + applySettingsFilter(query) + } + + private fun applySettingsFilter(query: String) { + searchJob?.cancel() + if (query.isBlank()) { + val results = if (activityView?.isSettingsSearchActive == true) { + arrayListOf() + } else { + presenter.getSettingsList() + } + showSearchResults(query, results) + return + } + + searchJob = viewLifecycleOwner.lifecycleScope.launch { + delay(SEARCH_QUERY_DEBOUNCE_MS) + val results = presenter.searchSettings(query) + if (activityView?.settingsSearchQuery == query) { + showSearchResults(query, results) + } + } + } + + private fun warmUpSearchIndex() { + if (searchIndexWarmupJob?.isActive == true) { + return + } + + searchIndexWarmupJob = viewLifecycleOwner.lifecycleScope.launch { + presenter.prepareSearchIndex() + } + } + + private fun showSearchResults(query: String, results: ArrayList<SettingsItem>) { + adapter!!.setSettings(results) + binding?.textNoSearchResults?.text = + getString(R.string.search_settings_no_results, query.trim()) + binding?.textNoSearchResults?.visibility = + if (query.isNotBlank() && results.isEmpty()) View.VISIBLE else View.GONE + binding?.listSettings?.visibility = + if (query.isNotBlank() && results.isEmpty()) View.GONE else View.VISIBLE } override fun loadSubMenu(menuKey: MenuTag) { @@ -139,13 +228,35 @@ class SettingsFragment : Fragment(), SettingsFragmentView { } activityView!!.showSettingsFragment( - menuKey, - null, - true, - requireArguments().getString(ARGUMENT_GAME_ID)!! + menuKey, null, true, requireArguments().getString(ARGUMENT_GAME_ID)!! + ) + } + + override fun loadSearchResult(menuKey: MenuTag, settingPosition: Int, extras: Bundle?) { + activityView!!.showSearchResult( + menuKey, settingPosition, requireArguments().getString(ARGUMENT_GAME_ID)!!, extras ) } + private fun highlightSearchResult(position: Int, setting: SettingsItem) { + val recyclerView = binding?.listSettings ?: return + highlightedSearchResult = setting + highlightedSearchResultPosition = position + recyclerView.post { + (recyclerView.findViewHolderForAdapterPosition(position) as? SettingViewHolder) + ?.highlightSearchResult() + } + } + + private fun clearSearchResultHighlight() { + val recyclerView = binding?.listSettings + (recyclerView?.findViewHolderForAdapterPosition( + highlightedSearchResultPosition + ) as? SettingViewHolder)?.clearSearchResultHighlight() + highlightedSearchResult = null + highlightedSearchResultPosition = RecyclerView.NO_POSITION + } + override fun showDialogFragment(fragment: DialogFragment) { activityView!!.showDialogFragment(fragment) } @@ -157,7 +268,11 @@ class SettingsFragment : Fragment(), SettingsFragmentView { override val settings: Settings? get() = presenter.settings - override fun onSettingChanged() { + override fun onSettingChanged(setting: SettingsItem?) { + if (setting == null || setting === highlightedSearchResult) { + clearSearchResultHighlight() + } + presenter.invalidateSearchIndex() activityView!!.onSettingChanged() } @@ -174,6 +289,10 @@ class SettingsFragment : Fragment(), SettingsFragmentView { return activityView!!.hasMenuTagActionForValue(menuTag, value) } + override fun getMenuTagActionExtras(menuTag: MenuTag, value: Int): Bundle? { + return activityView!!.getMenuTagActionExtras(menuTag, value) + } + override var isMappingAllDevices: Boolean get() = activityView!!.isMappingAllDevices set(allDevices) { @@ -203,17 +322,13 @@ class SettingsFragment : Fragment(), SettingsFragmentView { } val msg = "${presenter.gpuDriver!!.name} ${presenter.gpuDriver!!.driverVersion}" - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.gpu_driver_dialog_title)) - .setMessage(msg) - .setNegativeButton(android.R.string.cancel, null) + MaterialAlertDialogBuilder(requireContext()).setTitle(getString(R.string.gpu_driver_dialog_title)) + .setMessage(msg).setNegativeButton(android.R.string.cancel, null) .setNeutralButton(R.string.gpu_driver_dialog_system) { _: DialogInterface?, _: Int -> presenter.useSystemDriver() - } - .setPositiveButton(R.string.gpu_driver_dialog_install) { _: DialogInterface?, _: Int -> + }.setPositiveButton(R.string.gpu_driver_dialog_install) { _: DialogInterface?, _: Int -> askForDriverFile() - } - .show() + }.show() } override fun getFragmentLifecycle(): Lifecycle { @@ -230,16 +345,12 @@ class SettingsFragment : Fragment(), SettingsFragmentView { override fun onDriverInstallDone(result: GpuDriverInstallResult) { val view = binding?.root ?: return - Snackbar - .make(view, resolveInstallResultString(result), Snackbar.LENGTH_LONG) - .show() + Snackbar.make(view, resolveInstallResultString(result), Snackbar.LENGTH_LONG).show() } override fun onDriverUninstallDone() { Toast.makeText( - requireContext(), - R.string.gpu_driver_dialog_uninstall_done, - Toast.LENGTH_SHORT + requireContext(), R.string.gpu_driver_dialog_uninstall_done, Toast.LENGTH_SHORT ).show() } @@ -256,6 +367,8 @@ class SettingsFragment : Fragment(), SettingsFragmentView { companion object { private const val ARGUMENT_MENU_TAG = "menu_tag" private const val ARGUMENT_GAME_ID = "game_id" + const val ARGUMENT_SCROLL_TO_SETTING_POSITION = "scroll_to_setting_position" + private const val SEARCH_QUERY_DEBOUNCE_MS = 120L private val titles: MutableMap<MenuTag, Int> = EnumMap(MenuTag::class.java) init { diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt index a9ca9c3c3d..18a28d1428 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentPresenter.kt @@ -12,7 +12,11 @@ import androidx.appcompat.app.AppCompatActivity import androidx.collection.ArraySet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.dolphinemu.dolphinemu.NativeLibrary import org.dolphinemu.dolphinemu.R @@ -29,28 +33,63 @@ import org.dolphinemu.dolphinemu.features.input.model.view.InputDeviceSetting import org.dolphinemu.dolphinemu.features.input.model.view.InputMappingControlSetting import org.dolphinemu.dolphinemu.features.input.ui.ProfileDialog import org.dolphinemu.dolphinemu.features.input.ui.ProfileDialogPresenter -import org.dolphinemu.dolphinemu.features.settings.model.* -import org.dolphinemu.dolphinemu.features.settings.model.view.* +import org.dolphinemu.dolphinemu.features.settings.model.AbstractBooleanSetting +import org.dolphinemu.dolphinemu.features.settings.model.AbstractIntSetting +import org.dolphinemu.dolphinemu.features.settings.model.AchievementModel import org.dolphinemu.dolphinemu.features.settings.model.AchievementModel.logout +import org.dolphinemu.dolphinemu.features.settings.model.AdHocBooleanSetting +import org.dolphinemu.dolphinemu.features.settings.model.AdHocStringSetting +import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting +import org.dolphinemu.dolphinemu.features.settings.model.FloatSetting +import org.dolphinemu.dolphinemu.features.settings.model.IntSetting +import org.dolphinemu.dolphinemu.features.settings.model.PostProcessing +import org.dolphinemu.dolphinemu.features.settings.model.ScaledIntSetting +import org.dolphinemu.dolphinemu.features.settings.model.Settings +import org.dolphinemu.dolphinemu.features.settings.model.StringSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.DateTimeChoiceSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.DirectoryPicker +import org.dolphinemu.dolphinemu.features.settings.model.view.FilePicker +import org.dolphinemu.dolphinemu.features.settings.model.view.FloatSliderSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.HeaderSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.HyperLinkHeaderSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.InputStringSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.IntSliderSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.InvertedSwitchSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.LogSwitchSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.PercentSliderSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.RunRunnable +import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem +import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsSearchResult +import org.dolphinemu.dolphinemu.features.settings.model.view.SingleChoiceSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.SingleChoiceSettingDynamicDescriptions +import org.dolphinemu.dolphinemu.features.settings.model.view.StringSingleChoiceSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.SubmenuSetting +import org.dolphinemu.dolphinemu.features.settings.model.view.SwitchSetting import org.dolphinemu.dolphinemu.model.GpuDriverMetadata -import org.dolphinemu.dolphinemu.utils.* -import kotlin.collections.ArrayList +import org.dolphinemu.dolphinemu.utils.BooleanSupplier +import org.dolphinemu.dolphinemu.utils.EGLHelper +import org.dolphinemu.dolphinemu.utils.GpuDriverHelper +import org.dolphinemu.dolphinemu.utils.GpuDriverInstallResult +import org.dolphinemu.dolphinemu.utils.ThemeHelper +import org.dolphinemu.dolphinemu.utils.ThreadUtil +import org.dolphinemu.dolphinemu.utils.WiiUtils +import java.util.Locale import kotlin.math.ceil import kotlin.math.floor class SettingsFragmentPresenter( - private val fragmentView: SettingsFragmentView, - private val context: Context + private val fragmentView: SettingsFragmentView, private val context: Context ) { private lateinit var menuTag: MenuTag private var gameId: String? = null private var settingsList: ArrayList<SettingsItem>? = null + private var searchableSettings: List<SearchableSetting>? = null + private val searchIndexMutex = Mutex() + private var searchIndexGeneration = 0 private var hasOldControllerSettings = false - - private var serialPort1Type = 0 - private var controllerNumber = 0 - private var controllerType = 0 + private var menuExtras = Bundle() + private var shouldUpdateWarnings = true var gpuDriver: GpuDriverMetadata? = null private val libNameSetting: StringSetting = StringSetting.GFX_DRIVER_LIB_NAME @@ -58,23 +97,11 @@ class SettingsFragmentPresenter( fun onCreate(menuTag: MenuTag, gameId: String?, extras: Bundle) { this.gameId = gameId this.menuTag = menuTag + menuExtras = Bundle(extras) - if (menuTag.isGCPadMenu || menuTag.isWiimoteExtensionMenu) { - controllerNumber = menuTag.subType - controllerType = extras.getInt(ARG_CONTROLLER_TYPE) - } else if (menuTag.isWiimoteMenu || menuTag.isWiimoteSubmenu) { - controllerNumber = menuTag.subType - } else if (menuTag.isSerialPort1Menu) { - serialPort1Type = extras.getInt(ARG_SERIALPORT1_TYPE) - } else if ( - menuTag == MenuTag.GRAPHICS - && this.gameId.isNullOrEmpty() - && NativeLibrary.IsUninitialized() - && GpuDriverHelper.supportsCustomDriverLoading() - ) { - this.gpuDriver = - GpuDriverHelper.getInstalledDriverMetadata() - ?: GpuDriverHelper.getSystemDriverMetadata(context.applicationContext) + if (menuTag == MenuTag.GRAPHICS && this.gameId.isNullOrEmpty() && NativeLibrary.IsUninitialized() && GpuDriverHelper.supportsCustomDriverLoading()) { + this.gpuDriver = GpuDriverHelper.getInstalledDriverMetadata() + ?: GpuDriverHelper.getSystemDriverMetadata(context.applicationContext) } } @@ -100,85 +127,223 @@ class SettingsFragmentPresenter( } fun loadSettingsList() { - val sl = ArrayList<SettingsItem>() - when (menuTag) { - MenuTag.SETTINGS -> addTopLevelSettings(sl) - MenuTag.CONFIG -> addConfigSettings(sl) - MenuTag.CONFIG_GENERAL -> addGeneralSettings(sl) - MenuTag.CONFIG_INTERFACE -> addInterfaceSettings(sl) - MenuTag.CONFIG_AUDIO -> addAudioSettings(sl) - MenuTag.CONFIG_PATHS -> addPathsSettings(sl) - MenuTag.CONFIG_GAME_CUBE -> addGameCubeSettings(sl) - MenuTag.CONFIG_WII -> addWiiSettings(sl) - MenuTag.CONFIG_ACHIEVEMENTS -> addAchievementSettings(sl); - MenuTag.CONFIG_ADVANCED -> addAdvancedSettings(sl) - MenuTag.GRAPHICS -> addGraphicsSettings(sl) - MenuTag.CONFIG_SERIALPORT1 -> addSerialPortSubSettings(sl, serialPort1Type) - MenuTag.GCPAD_TYPE -> addGcPadSettings(sl) - MenuTag.WIIMOTE -> addWiimoteSettings(sl) - MenuTag.ENHANCEMENTS -> addEnhanceSettings(sl) - MenuTag.COLOR_CORRECTION -> addColorCorrectionSettings(sl) - MenuTag.STEREOSCOPY -> addStereoSettings(sl) - MenuTag.HACKS -> addHackSettings(sl) - MenuTag.STATISTICS -> addStatisticsSettings(sl) - MenuTag.ADVANCED_GRAPHICS -> addAdvancedGraphicsSettings(sl) - MenuTag.CONFIG_LOG -> addLogConfigurationSettings(sl) - MenuTag.DEBUG -> addDebugSettings(sl) - MenuTag.GCPAD_1, - MenuTag.GCPAD_2, - MenuTag.GCPAD_3, - MenuTag.GCPAD_4 -> addGcPadSubSettings( - sl, - controllerNumber, - controllerType - ) + invalidateSearchIndex() + settingsList = tryBuildSettingsList(menuTag, menuExtras, true) + ?: throw UnsupportedOperationException("Unimplemented menu") + fragmentView.showSettingsList(settingsList!!) + } - MenuTag.WIIMOTE_1, - MenuTag.WIIMOTE_2, - MenuTag.WIIMOTE_3, - MenuTag.WIIMOTE_4 -> addWiimoteSubSettings( - sl, - controllerNumber - ) + fun getSettingsList(): ArrayList<SettingsItem> = settingsList ?: arrayListOf() - MenuTag.WIIMOTE_EXTENSION_1, - MenuTag.WIIMOTE_EXTENSION_2, - MenuTag.WIIMOTE_EXTENSION_3, - MenuTag.WIIMOTE_EXTENSION_4 -> addExtensionTypeSettings( - sl, - controllerNumber, - controllerType - ) + suspend fun prepareSearchIndex() { + getSearchIndex() + } - MenuTag.WIIMOTE_GENERAL_1, - MenuTag.WIIMOTE_GENERAL_2, - MenuTag.WIIMOTE_GENERAL_3, - MenuTag.WIIMOTE_GENERAL_4 -> addWiimoteGeneralSubSettings( - sl, - controllerNumber - ) + suspend fun searchSettings(query: String): ArrayList<SettingsItem> { + val normalizedQuery = query.trim().lowercase(Locale.ROOT) + if (normalizedQuery.isEmpty()) { + return getSettingsList() + } - MenuTag.WIIMOTE_MOTION_SIMULATION_1, - MenuTag.WIIMOTE_MOTION_SIMULATION_2, - MenuTag.WIIMOTE_MOTION_SIMULATION_3, - MenuTag.WIIMOTE_MOTION_SIMULATION_4 -> addWiimoteMotionSimulationSubSettings( - sl, - controllerNumber - ) + val terms = normalizedQuery.split(Regex("\\s+")) + val index = getSearchIndex() + return withContext(Dispatchers.Default) { + index.asSequence() + .filter { setting -> terms.all(setting.normalizedSearchText::contains) } + .map { setting -> + val score = when { + setting.normalizedName == normalizedQuery -> 0 + setting.normalizedName.startsWith(normalizedQuery) -> 1 + terms.all(setting.normalizedName::contains) -> 2 + setting.normalizedCategory.contains(normalizedQuery) -> 3 + else -> 4 + } + score to SettingsSearchResult( + setting.name, + setting.description, + setting.menuTag, + setting.position, + setting.navigationExtras + ) + } + .sortedWith(compareBy<Pair<Int, SettingsSearchResult>> { it.first }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.second.name.toString() }) + .mapTo(ArrayList()) { it.second } + } + } - MenuTag.WIIMOTE_MOTION_INPUT_1, - MenuTag.WIIMOTE_MOTION_INPUT_2, - MenuTag.WIIMOTE_MOTION_INPUT_3, - MenuTag.WIIMOTE_MOTION_INPUT_4 -> addWiimoteMotionInputSubSettings( - sl, - controllerNumber - ) + private suspend fun getSearchIndex(): List<SearchableSetting> { + return searchIndexMutex.withLock { + searchableSettings?.let { return@withLock it } + + val generation = searchIndexGeneration + val index = withContext(Dispatchers.IO) { + buildSearchIndex() + } + if (generation == searchIndexGeneration) { + searchableSettings = index + } + index + } + } - else -> throw UnsupportedOperationException("Unimplemented menu") + private suspend fun buildSearchIndex(): List<SearchableSetting> { + return getSearchableMenus().flatMap { searchableMenu -> + currentCoroutineContext().ensureActive() + searchableMenu.settings.withIndex().asSequence() + .filterNot { it.value is HeaderSetting || it.value is SubmenuSetting } + .map { indexedItem -> + val item = indexedItem.value + val name = item.name.toString() + val description = item.description.toString() + SearchableSetting( + name = name, + description = searchableMenu.category, + menuTag = searchableMenu.menuTag, + navigationExtras = searchableMenu.navigationExtras, + position = indexedItem.index, + normalizedName = name.lowercase(Locale.ROOT), + normalizedCategory = searchableMenu.category.lowercase(Locale.ROOT), + normalizedSearchText = "$name $description ${searchableMenu.category}".lowercase( + Locale.ROOT + ) + ) + }.toList() } + } - settingsList = sl - fragmentView.showSettingsList(settingsList!!) + fun invalidateSearchIndex() { + searchableSettings = null + searchIndexGeneration++ + } + + private suspend fun getSearchableMenus(): List<SearchableMenu> { + val menus = mutableListOf<SearchableMenu>() + val visitedMenus = mutableSetOf<String>() + + suspend fun visitMenu( + menuTag: MenuTag, + category: String, + navigationExtras: Bundle? = null, + actionValue: Int? = null + ) { + currentCoroutineContext().ensureActive() + if (!visitedMenus.add("$menuTag:$actionValue")) { + return + } + + val items = tryBuildSettingsList(menuTag, navigationExtras ?: Bundle(), false) ?: return + if (menuTag != MenuTag.SETTINGS) { + menus += SearchableMenu(menuTag, category, navigationExtras, items) + } + + suspend fun visitMenuAction( + childMenuTag: MenuTag?, selectedValue: Int, childCategory: String + ) { + if (childMenuTag != null && fragmentView.hasMenuTagActionForValue( + childMenuTag, selectedValue + ) + ) { + visitMenu( + childMenuTag, + childCategory, + fragmentView.getMenuTagActionExtras(childMenuTag, selectedValue), + selectedValue + ) + } + } + + for (item in items) { + currentCoroutineContext().ensureActive() + val childCategory = if (category.isEmpty()) item.name.toString() + else context.getString( + R.string.search_settings_category_path, category, item.name + ) + + when (item) { + is SubmenuSetting -> visitMenu(item.menuKey, childCategory) + is SingleChoiceSetting -> { + visitMenuAction(item.menuTag, item.selectedValue, childCategory) + } + + is StringSingleChoiceSetting -> { + visitMenuAction(item.menuTag, item.selectedValueIndex, childCategory) + } + } + } + } + + visitMenu(MenuTag.SETTINGS, "") + return menus + } + + private fun tryBuildSettingsList( + targetMenuTag: MenuTag, extras: Bundle, updateWarnings: Boolean + ): ArrayList<SettingsItem>? { + val sl = ArrayList<SettingsItem>() + var isSupportedMenu = true + val previousMenuTag = menuTag + val previousShouldUpdateWarnings = shouldUpdateWarnings + menuTag = targetMenuTag + shouldUpdateWarnings = updateWarnings + try { + when (targetMenuTag) { + MenuTag.SETTINGS -> addTopLevelSettings(sl) + MenuTag.CONFIG -> addConfigSettings(sl) + MenuTag.CONFIG_GENERAL -> addGeneralSettings(sl) + MenuTag.CONFIG_INTERFACE -> addInterfaceSettings(sl) + MenuTag.CONFIG_AUDIO -> addAudioSettings(sl) + MenuTag.CONFIG_PATHS -> addPathsSettings(sl) + MenuTag.CONFIG_GAME_CUBE -> addGameCubeSettings(sl) + MenuTag.CONFIG_WII -> addWiiSettings(sl) + MenuTag.CONFIG_ACHIEVEMENTS -> addAchievementSettings(sl) + MenuTag.CONFIG_ADVANCED -> addAdvancedSettings(sl) + MenuTag.GRAPHICS -> addGraphicsSettings(sl) + MenuTag.CONFIG_SERIALPORT1 -> addSerialPortSubSettings( + sl, extras.getInt(ARG_SERIALPORT1_TYPE) + ) + + MenuTag.GCPAD_TYPE -> addGcPadSettings(sl) + MenuTag.HOTKEYS -> addHotkeySettings(sl) + MenuTag.WIIMOTE -> addWiimoteSettings(sl) + MenuTag.ENHANCEMENTS -> addEnhanceSettings(sl) + MenuTag.COLOR_CORRECTION -> addColorCorrectionSettings(sl) + MenuTag.STEREOSCOPY -> addStereoSettings(sl) + MenuTag.HACKS -> addHackSettings(sl) + MenuTag.STATISTICS -> addStatisticsSettings(sl) + MenuTag.ADVANCED_GRAPHICS -> addAdvancedGraphicsSettings(sl) + MenuTag.CONFIG_LOG -> addLogConfigurationSettings(sl) + MenuTag.DEBUG -> addDebugSettings(sl) + MenuTag.GCPAD_1, MenuTag.GCPAD_2, MenuTag.GCPAD_3, MenuTag.GCPAD_4 -> addGcPadSubSettings( + sl, targetMenuTag.subType, extras.getInt(ARG_CONTROLLER_TYPE) + ) + + MenuTag.WIIMOTE_1, MenuTag.WIIMOTE_2, MenuTag.WIIMOTE_3, MenuTag.WIIMOTE_4 -> addWiimoteSubSettings( + sl, targetMenuTag.subType + ) + + MenuTag.WIIMOTE_EXTENSION_1, MenuTag.WIIMOTE_EXTENSION_2, MenuTag.WIIMOTE_EXTENSION_3, MenuTag.WIIMOTE_EXTENSION_4 -> addExtensionTypeSettings( + sl, targetMenuTag.subType, extras.getInt(ARG_CONTROLLER_TYPE) + ) + + MenuTag.WIIMOTE_GENERAL_1, MenuTag.WIIMOTE_GENERAL_2, MenuTag.WIIMOTE_GENERAL_3, MenuTag.WIIMOTE_GENERAL_4 -> addWiimoteGeneralSubSettings( + sl, targetMenuTag.subType + ) + + MenuTag.WIIMOTE_MOTION_SIMULATION_1, MenuTag.WIIMOTE_MOTION_SIMULATION_2, MenuTag.WIIMOTE_MOTION_SIMULATION_3, MenuTag.WIIMOTE_MOTION_SIMULATION_4 -> addWiimoteMotionSimulationSubSettings( + sl, targetMenuTag.subType + ) + + MenuTag.WIIMOTE_MOTION_INPUT_1, MenuTag.WIIMOTE_MOTION_INPUT_2, MenuTag.WIIMOTE_MOTION_INPUT_3, MenuTag.WIIMOTE_MOTION_INPUT_4 -> addWiimoteMotionInputSubSettings( + sl, targetMenuTag.subType + ) + + else -> isSupportedMenu = false + } + } finally { + menuTag = previousMenuTag + shouldUpdateWarnings = previousShouldUpdateWarnings + } + return if (isSupportedMenu) sl else null } private fun addTopLevelSettings(sl: ArrayList<SettingsItem>) { @@ -190,6 +355,11 @@ class SettingsFragmentPresenter( sl.add(SubmenuSetting(context, R.string.wiimote_settings, MenuTag.WIIMOTE)) } + // Hotkeys live in Hotkeys.ini and are global, so there is nothing to edit per game. + if (gameId.isNullOrEmpty()) { + sl.add(SubmenuSetting(context, R.string.hotkey_settings, MenuTag.HOTKEYS)) + } + sl.add(HeaderSetting(context, R.string.setting_clear_info, 0)) } @@ -205,9 +375,9 @@ class SettingsFragmentPresenter( sl.add(SubmenuSetting(context, R.string.log_submenu, MenuTag.CONFIG_LOG)) sl.add(SubmenuSetting(context, R.string.debug_submenu, MenuTag.DEBUG)) sl.add( - RunRunnable(context, R.string.user_data_submenu, 0, 0, 0, false) - { UserDataActivity.launch(context) } - ) + RunRunnable( + context, R.string.user_data_submenu, 0, 0, 0, false + ) { UserDataActivity.launch(context) }) } private fun addGeneralSettings(sl: ArrayList<SettingsItem>) { @@ -221,10 +391,7 @@ class SettingsFragmentPresenter( ) sl.add( SwitchSetting( - context, - BooleanSetting.MAIN_ENABLE_CHEATS, - R.string.enable_cheats, - 0 + context, BooleanSetting.MAIN_ENABLE_CHEATS, R.string.enable_cheats, 0 ) ) sl.add( @@ -237,10 +404,7 @@ class SettingsFragmentPresenter( ) sl.add( SwitchSetting( - context, - BooleanSetting.MAIN_AUTO_DISC_CHANGE, - R.string.auto_disc_change, - 0 + context, BooleanSetting.MAIN_AUTO_DISC_CHANGE, R.string.auto_disc_change, 0 ) ) sl.add( @@ -268,10 +432,7 @@ class SettingsFragmentPresenter( ) sl.add( SwitchSetting( - context, - BooleanSetting.MAIN_ANALYTICS_ENABLED, - R.string.analytics, - 0 + context, BooleanSetting.MAIN_ANALYTICS_ENABLED, R.string.analytics, 0 ) ) sl.add( @@ -282,8 +443,7 @@ class SettingsFragmentPresenter( R.string.analytics_new_id_confirmation, 0, true - ) { NativeLibrary.GenerateNewStatisticsId() } - ) + ) { NativeLibrary.GenerateNewStatisticsId() }) sl.add( SwitchSetting( context, @@ -298,8 +458,9 @@ class SettingsFragmentPresenter( // Hide the orientation setting if the device only supports one orientation. Old devices which // support both portrait and landscape may report support for neither, so we use ==, not &&. val packageManager = context.packageManager - if (packageManager.hasSystemFeature(PackageManager.FEATURE_SCREEN_PORTRAIT) == - packageManager.hasSystemFeature(PackageManager.FEATURE_SCREEN_LANDSCAPE) + if (packageManager.hasSystemFeature(PackageManager.FEATURE_SCREEN_PORTRAIT) == packageManager.hasSystemFeature( + PackageManager.FEATURE_SCREEN_LANDSCAPE + ) ) { sl.add( SingleChoiceSetting( @@ -368,8 +529,7 @@ class SettingsFragmentPresenter( override fun setInt(settings: Settings, newValue: Int) { IntSetting.MAIN_INTERFACE_THEME.setInt(settings, newValue) ThemeHelper.saveTheme( - (fragmentView.fragmentActivity as AppCompatActivity), - newValue + (fragmentView.fragmentActivity as AppCompatActivity), newValue ) } } @@ -417,8 +577,7 @@ class SettingsFragmentPresenter( override fun setInt(settings: Settings, newValue: Int) { IntSetting.MAIN_INTERFACE_THEME_MODE.setInt(settings, newValue) ThemeHelper.saveThemeMode( - (fragmentView.fragmentActivity as AppCompatActivity), - newValue + (fragmentView.fragmentActivity as AppCompatActivity), newValue ) } } @@ -451,8 +610,7 @@ class SettingsFragmentPresenter( override fun setBoolean(settings: Settings, newValue: Boolean) { BooleanSetting.MAIN_USE_BLACK_BACKGROUNDS.setBoolean(settings, newValue) ThemeHelper.saveBackgroundSetting( - (fragmentView.fragmentActivity as AppCompatActivity), - newValue + (fragmentView.fragmentActivity as AppCompatActivity), newValue ) } } @@ -501,17 +659,16 @@ class SettingsFragmentPresenter( } override val isOverridden: Boolean - get() = BooleanSetting.MAIN_DSP_HLE.isOverridden || - BooleanSetting.MAIN_DSP_JIT.isOverridden + get() = BooleanSetting.MAIN_DSP_HLE.isOverridden || BooleanSetting.MAIN_DSP_JIT.isOverridden override val isRuntimeEditable: Boolean - get() = BooleanSetting.MAIN_DSP_HLE.isRuntimeEditable && - BooleanSetting.MAIN_DSP_JIT.isRuntimeEditable + get() = BooleanSetting.MAIN_DSP_HLE.isRuntimeEditable && BooleanSetting.MAIN_DSP_JIT.isRuntimeEditable override fun delete(settings: Settings): Boolean { // Not short circuiting - return BooleanSetting.MAIN_DSP_HLE.delete(settings) and - BooleanSetting.MAIN_DSP_JIT.delete(settings) + return BooleanSetting.MAIN_DSP_HLE.delete(settings) and BooleanSetting.MAIN_DSP_JIT.delete( + settings + ) } } @@ -566,14 +723,7 @@ class SettingsFragmentPresenter( ) sl.add( IntSliderSetting( - context, - IntSetting.MAIN_AUDIO_VOLUME, - R.string.audio_volume, - 0, - 0, - 100, - "%", - 1 + context, IntSetting.MAIN_AUDIO_VOLUME, R.string.audio_volume, 0, 0, 100, "%", 1 ) ) } @@ -581,10 +731,7 @@ class SettingsFragmentPresenter( private fun addPathsSettings(sl: ArrayList<SettingsItem>) { sl.add( SwitchSetting( - context, - BooleanSetting.MAIN_RECURSIVE_ISO_PATHS, - R.string.search_subfolders, - 0 + context, BooleanSetting.MAIN_RECURSIVE_ISO_PATHS, R.string.search_subfolders, 0 ) ) sl.add( @@ -802,10 +949,7 @@ class SettingsFragmentPresenter( ) sl.add( SwitchSetting( - context, - BooleanSetting.MAIN_ALLOW_SD_WRITES, - R.string.wii_sd_card_allow_writes, - 0 + context, BooleanSetting.MAIN_ALLOW_SD_WRITES, R.string.wii_sd_card_allow_writes, 0 ) ) sl.add( @@ -847,8 +991,7 @@ class SettingsFragmentPresenter( R.string.wii_sd_card_folder_to_file_confirmation, 0, false - ) { convertOnThread { WiiUtils.syncSdFolderToSdImage() } } - ) + ) { convertOnThread { WiiUtils.syncSdFolderToSdImage() } }) sl.add( RunRunnable( context, @@ -857,16 +1000,12 @@ class SettingsFragmentPresenter( R.string.wii_sd_card_file_to_folder_confirmation, 0, false - ) { convertOnThread { WiiUtils.syncSdImageToSdFolder() } } - ) + ) { convertOnThread { WiiUtils.syncSdImageToSdFolder() } }) sl.add(HeaderSetting(context, R.string.wii_wiimote_settings, 0)) sl.add( SwitchSetting( - context, - BooleanSetting.SYSCONF_WIIMOTE_MOTOR, - R.string.wiimote_rumble, - 0 + context, BooleanSetting.SYSCONF_WIIMOTE_MOTOR, R.string.wiimote_rumble, 0 ) ) sl.add( @@ -939,18 +1078,12 @@ class SettingsFragmentPresenter( ) sl.add( SwitchSetting( - context, - BooleanSetting.MAIN_EMULATE_WII_SPEAK, - R.string.emulate_wii_speak, - 0 + context, BooleanSetting.MAIN_EMULATE_WII_SPEAK, R.string.emulate_wii_speak, 0 ) ) sl.add( SwitchSetting( - context, - BooleanSetting.MAIN_WII_SPEAK_MUTED, - R.string.mute_wii_speak, - 0 + context, BooleanSetting.MAIN_WII_SPEAK_MUTED, R.string.mute_wii_speak, 0 ) ) } @@ -962,10 +1095,8 @@ class SettingsFragmentPresenter( override fun setBoolean(settings: Settings, newValue: Boolean) { BooleanSetting.ACHIEVEMENTS_ENABLED.setBoolean(settings, newValue) - if (newValue) - AchievementModel.init() - else - AchievementModel.shutdown() + if (newValue) AchievementModel.init() + else AchievementModel.shutdown() loadSettingsList() } @@ -985,38 +1116,25 @@ class SettingsFragmentPresenter( sl.add( SwitchSetting( - context, - achievementsEnabledSetting, - R.string.achievements_enabled, - 0 + context, achievementsEnabledSetting, R.string.achievements_enabled, 0 ) ) if (BooleanSetting.ACHIEVEMENTS_ENABLED.boolean) { if (StringSetting.ACHIEVEMENTS_API_TOKEN.string == "") { sl.add( RunRunnable( - context, - R.string.achievements_login, - 0, - 0, - 0, - false + context, R.string.achievements_login, 0, 0, 0, false ) { - fragmentView.showDialogFragment(LoginDialog(this)) - loadSettingsList() + fragmentView.showDialogFragment(LoginDialog(this)) + loadSettingsList() }) } else { sl.add( RunRunnable( - context, - R.string.achievements_logout, - 0, - 0, - 0, - false + context, R.string.achievements_logout, 0, 0, 0, false ) { - logout() - loadSettingsList() + logout() + loadSettingsList() }) } sl.add( @@ -1075,6 +1193,14 @@ class SettingsFragmentPresenter( 0 ) ) + sl.add( + SwitchSetting( + context, + BooleanSetting.ACHIEVEMENTS_SOUND_ENABLED, + R.string.achievements_sound_enabled, + 0 + ) + ) } } @@ -1112,17 +1238,16 @@ class SettingsFragmentPresenter( } override val isOverridden: Boolean - get() = BooleanSetting.MAIN_SYNC_ON_SKIP_IDLE.isOverridden || - BooleanSetting.MAIN_SYNC_GPU.isOverridden + get() = BooleanSetting.MAIN_SYNC_ON_SKIP_IDLE.isOverridden || BooleanSetting.MAIN_SYNC_GPU.isOverridden override val isRuntimeEditable: Boolean - get() = BooleanSetting.MAIN_SYNC_ON_SKIP_IDLE.isRuntimeEditable && - BooleanSetting.MAIN_SYNC_GPU.isRuntimeEditable + get() = BooleanSetting.MAIN_SYNC_ON_SKIP_IDLE.isRuntimeEditable && BooleanSetting.MAIN_SYNC_GPU.isRuntimeEditable override fun delete(settings: Settings): Boolean { // Not short circuiting - return BooleanSetting.MAIN_SYNC_ON_SKIP_IDLE.delete(settings) and - BooleanSetting.MAIN_SYNC_GPU.delete(settings) + return BooleanSetting.MAIN_SYNC_ON_SKIP_IDLE.delete(settings) and BooleanSetting.MAIN_SYNC_GPU.delete( + settings + ) } } @@ -1243,26 +1368,12 @@ class SettingsFragmentPresenter( ) sl.add( IntSliderSetting( - context, - mem1Size, - R.string.main_mem1_size, - 0, - 24, - 64, - "MB", - 1 + context, mem1Size, R.string.main_mem1_size, 0, 24, 64, "MB", 1 ) ) sl.add( IntSliderSetting( - context, - mem2Size, - R.string.main_mem2_size, - 0, - 64, - 128, - "MB", - 1 + context, mem2Size, R.string.main_mem2_size, 0, 64, 256, "MB", 1 ) ) @@ -1289,10 +1400,7 @@ class SettingsFragmentPresenter( ) sl.add( DateTimeChoiceSetting( - context, - StringSetting.MAIN_CUSTOM_RTC_VALUE, - R.string.set_custom_rtc, - 0 + context, StringSetting.MAIN_CUSTOM_RTC_VALUE, R.string.set_custom_rtc, 0 ) ) @@ -1520,42 +1628,29 @@ class SettingsFragmentPresenter( sl.add(HeaderSetting(context, R.string.graphics_more_settings, 0)) sl.add( SubmenuSetting( - context, - R.string.enhancements_submenu, - MenuTag.ENHANCEMENTS + context, R.string.enhancements_submenu, MenuTag.ENHANCEMENTS ) ) sl.add( SubmenuSetting( - context, - R.string.hacks_submenu, - MenuTag.HACKS + context, R.string.hacks_submenu, MenuTag.HACKS ) ) sl.add( SubmenuSetting( - context, - R.string.statistics_submenu, - MenuTag.STATISTICS + context, R.string.statistics_submenu, MenuTag.STATISTICS ) ) sl.add( SubmenuSetting( - context, - R.string.advanced_graphics_submenu, - MenuTag.ADVANCED_GRAPHICS + context, R.string.advanced_graphics_submenu, MenuTag.ADVANCED_GRAPHICS ) ) - if ( - this.gpuDriver != null && this.gameId.isNullOrEmpty() - && NativeLibrary.IsUninitialized() - && GpuDriverHelper.supportsCustomDriverLoading() - ) { + if (this.gpuDriver != null && this.gameId.isNullOrEmpty() && NativeLibrary.IsUninitialized() && GpuDriverHelper.supportsCustomDriverLoading()) { sl.add( SubmenuSetting( - context, - R.string.gpu_driver_submenu, MenuTag.GPU_DRIVERS + context, R.string.gpu_driver_submenu, MenuTag.GPU_DRIVERS ) ) } @@ -1604,9 +1699,7 @@ class SettingsFragmentPresenter( ) sl.add( SubmenuSetting( - context, - R.string.color_correction_submenu, - MenuTag.COLOR_CORRECTION + context, R.string.color_correction_submenu, MenuTag.COLOR_CORRECTION ) ) @@ -1697,9 +1790,7 @@ class SettingsFragmentPresenter( ) { sl.add( SubmenuSetting( - context, - R.string.stereoscopy_submenu, - MenuTag.STEREOSCOPY + context, R.string.stereoscopy_submenu, MenuTag.STEREOSCOPY ) ) } @@ -1743,10 +1834,7 @@ class SettingsFragmentPresenter( ) add( SwitchSetting( - context, - BooleanSetting.GFX_CC_CORRECT_GAMMA, - R.string.correct_sdr_gamma, - 0 + context, BooleanSetting.GFX_CC_CORRECT_GAMMA, R.string.correct_sdr_gamma, 0 ) ) } @@ -2053,10 +2141,10 @@ class SettingsFragmentPresenter( IntSetting.GFX_CROP_CUSTOM_LEFT, R.string.crop_custom_left, R.string.crop_custom_left_description, - min=0, - max=640, - units="px", - stepSize=1, + min = 0, + max = 640, + units = "px", + stepSize = 1, ) ) sl.add( @@ -2075,18 +2163,12 @@ class SettingsFragmentPresenter( sl.add(HeaderSetting(context, R.string.misc, 0)) sl.add( SwitchSetting( - context, - BooleanSetting.SYSCONF_PROGRESSIVE_SCAN, - R.string.progressive_scan, - 0 + context, BooleanSetting.SYSCONF_PROGRESSIVE_SCAN, R.string.progressive_scan, 0 ) ) sl.add( SwitchSetting( - context, - BooleanSetting.GFX_VSYNC, - R.string.vsync, - R.string.vsync_description + context, BooleanSetting.GFX_VSYNC, R.string.vsync, R.string.vsync_description ) ) sl.add( @@ -2242,35 +2324,21 @@ class SettingsFragmentPresenter( IntSetting.LOGGER_VERBOSITY, R.string.log_verbosity, 0, - getLogVerbosityEntries(), getLogVerbosityValues() + getLogVerbosityEntries(), + getLogVerbosityValues() ) ) sl.add( RunRunnable( - context, - R.string.log_enable_all, - 0, - R.string.log_enable_all_confirmation, - 0, - true + context, R.string.log_enable_all, 0, R.string.log_enable_all_confirmation, 0, true ) { setAllLogTypes(true) }) sl.add( RunRunnable( - context, - R.string.log_disable_all, - 0, - R.string.log_disable_all_confirmation, - 0, - true + context, R.string.log_disable_all, 0, R.string.log_disable_all_confirmation, 0, true ) { setAllLogTypes(false) }) sl.add( RunRunnable( - context, - R.string.log_clear, - 0, - R.string.log_clear_confirmation, - 0, - true + context, R.string.log_clear, 0, R.string.log_clear_confirmation, 0, true ) { SettingsAdapter.clearLog() }) sl.add(HeaderSetting(context, R.string.log_types, 0)) @@ -2283,10 +2351,7 @@ class SettingsFragmentPresenter( sl.add(HeaderSetting(context, R.string.debug_warning, 0)) sl.add( InvertedSwitchSetting( - context, - BooleanSetting.MAIN_FASTMEM, - R.string.debug_fastmem, - 0 + context, BooleanSetting.MAIN_FASTMEM, R.string.debug_fastmem, 0 ) ) sl.add( @@ -2299,10 +2364,7 @@ class SettingsFragmentPresenter( ) sl.add( InvertedSwitchSetting( - context, - BooleanSetting.MAIN_FASTMEM_ARENA, - R.string.debug_fastmem_arena, - 0 + context, BooleanSetting.MAIN_FASTMEM_ARENA, R.string.debug_fastmem_arena, 0 ) ) sl.add( @@ -2321,7 +2383,7 @@ class SettingsFragmentPresenter( BooleanSetting.MAIN_DEBUG_JIT_ENABLE_PROFILING, R.string.debug_jit_enable_block_profiling, 0 - ) + ) ) sl.add( RunRunnable( @@ -2331,26 +2393,16 @@ class SettingsFragmentPresenter( R.string.debug_jit_wipe_block_profiling_data_alert, 0, true - ) { NativeLibrary.WipeJitBlockProfilingData() } - ) + ) { NativeLibrary.WipeJitBlockProfilingData() }) sl.add( RunRunnable( - context, - R.string.debug_jit_write_block_log_dump, - 0, - 0, - 0, - true - ) { NativeLibrary.WriteJitBlockLogDump() } - ) + context, R.string.debug_jit_write_block_log_dump, 0, 0, 0, true + ) { NativeLibrary.WriteJitBlockLogDump() }) sl.add(HeaderSetting(context, R.string.debug_jit_header, 0)) sl.add( SwitchSetting( - context, - BooleanSetting.MAIN_DEBUG_JIT_OFF, - R.string.debug_jitoff, - 0 + context, BooleanSetting.MAIN_DEBUG_JIT_OFF, R.string.debug_jitoff, 0 ) ) sl.add( @@ -2387,18 +2439,12 @@ class SettingsFragmentPresenter( ) sl.add( SwitchSetting( - context, - BooleanSetting.MAIN_DEBUG_JIT_INTEGER_OFF, - R.string.debug_jitintegeroff, - 0 + context, BooleanSetting.MAIN_DEBUG_JIT_INTEGER_OFF, R.string.debug_jitintegeroff, 0 ) ) sl.add( SwitchSetting( - context, - BooleanSetting.MAIN_DEBUG_JIT_PAIRED_OFF, - R.string.debug_jitpairedoff, - 0 + context, BooleanSetting.MAIN_DEBUG_JIT_PAIRED_OFF, R.string.debug_jitpairedoff, 0 ) ) sl.add( @@ -2411,10 +2457,7 @@ class SettingsFragmentPresenter( ) sl.add( SwitchSetting( - context, - BooleanSetting.MAIN_DEBUG_JIT_BRANCH_OFF, - R.string.debug_jitbranchoff, - 0 + context, BooleanSetting.MAIN_DEBUG_JIT_BRANCH_OFF, R.string.debug_jitbranchoff, 0 ) ) sl.add( @@ -2474,7 +2517,36 @@ class SettingsFragmentPresenter( ) } - private fun addGcPadSubSettings(sl: ArrayList<SettingsItem>, gcPadNumber: Int, gcPadType: Int) { + /** + * Lists a subset of the hotkeys the native HotkeyScheduler dispatches. + */ + private fun addHotkeySettings(sl: ArrayList<SettingsItem>) { + val hotkeys = EmulatedController.getHotkeys() + + addControllerMetaSettings(sl, hotkeys) + + // Flattened {group index, control index} pairs. Each control group gets a header the first + // time one of its hotkeys shows up. + val indices = EmulatedController.getSupportedHotkeyIndices() + var currentGroupIndex = -1 + var i = 0 + while (i < indices.size) { + val groupIndex = indices[i] + val group = hotkeys.getGroup(groupIndex) + + if (groupIndex != currentGroupIndex) { + sl.add(HeaderSetting(group.getUiName(), "")) + currentGroupIndex = groupIndex + } + + sl.add(InputMappingControlSetting(group.getControl(indices[i + 1]), hotkeys)) + i += 2 + } + } + + private fun addGcPadSubSettings( + sl: ArrayList<SettingsItem>, gcPadNumber: Int, gcPadType: Int + ) { when (gcPadType) { 6, 8, 9, 10, 11 -> { // Emulated @@ -2484,9 +2556,10 @@ class SettingsFragmentPresenter( addControllerPerGameSettings(sl, gcPad, gcPadNumber) } else { addControllerMetaSettings(sl, gcPad) - addControllerMappingSettings(sl, gcPad, null) + addControllerMappingSettings(sl, gcPad, gcPadNumber, null) } } + 7 -> { // Emulated keyboard controller val gcKeyboard = EmulatedController.getGcKeyboard(gcPadNumber) @@ -2496,9 +2569,12 @@ class SettingsFragmentPresenter( } else { sl.add(HeaderSetting(context, R.string.keyboard_controller_warning, 0)) addControllerMetaSettings(sl, gcKeyboard) - addControllerMappingSettings(sl, gcKeyboard, null) + addControllerMappingSettings( + sl, gcKeyboard, gcPadNumber, null + ) } } + 12 -> { // Adapter sl.add( @@ -2521,7 +2597,9 @@ class SettingsFragmentPresenter( } } - private fun addWiimoteSubSettings(sl: ArrayList<SettingsItem>, wiimoteNumber: Int) { + private fun addWiimoteSubSettings( + sl: ArrayList<SettingsItem>, wiimoteNumber: Int + ) { val wiimote = EmulatedController.getWiimote(wiimoteNumber) if (!TextUtils.isEmpty(gameId)) { @@ -2557,39 +2635,40 @@ class SettingsFragmentPresenter( addControllerMappingSettings( sl, wiimote, + wiimoteNumber, ArraySet(listOf(ControlGroup.TYPE_ATTACHMENTS, ControlGroup.TYPE_OTHER)) ) } } private fun addExtensionTypeSettings( - sl: ArrayList<SettingsItem>, - wiimoteNumber: Int, - extensionType: Int + sl: ArrayList<SettingsItem>, wiimoteNumber: Int, extensionType: Int ) { addContainerMappingSettings( sl, EmulatedController.getWiimote(wiimoteNumber), EmulatedController.getWiimoteAttachment(wiimoteNumber, extensionType), + wiimoteNumber, null ) } - private fun addWiimoteGeneralSubSettings(sl: ArrayList<SettingsItem>, wiimoteNumber: Int) { + private fun addWiimoteGeneralSubSettings( + sl: ArrayList<SettingsItem>, wiimoteNumber: Int + ) { addControllerMappingSettings( sl, EmulatedController.getWiimote(wiimoteNumber), + wiimoteNumber, setOf(ControlGroup.TYPE_BUTTONS) ) } private fun addWiimoteMotionSimulationSubSettings( - sl: ArrayList<SettingsItem>, - wiimoteNumber: Int + sl: ArrayList<SettingsItem>, wiimoteNumber: Int ) { addControllerMappingSettings( - sl, EmulatedController.getWiimote(wiimoteNumber), - ArraySet( + sl, EmulatedController.getWiimote(wiimoteNumber), wiimoteNumber, ArraySet( listOf( ControlGroup.TYPE_FORCE, ControlGroup.TYPE_TILT, @@ -2600,10 +2679,11 @@ class SettingsFragmentPresenter( ) } - private fun addWiimoteMotionInputSubSettings(sl: ArrayList<SettingsItem>, wiimoteNumber: Int) { + private fun addWiimoteMotionInputSubSettings( + sl: ArrayList<SettingsItem>, wiimoteNumber: Int + ) { addControllerMappingSettings( - sl, EmulatedController.getWiimote(wiimoteNumber), - ArraySet( + sl, EmulatedController.getWiimote(wiimoteNumber), wiimoteNumber, ArraySet( listOf( ControlGroup.TYPE_IMU_ACCELEROMETER, ControlGroup.TYPE_IMU_GYROSCOPE, @@ -2621,9 +2701,7 @@ class SettingsFragmentPresenter( * @param controllerNumber The index of the controller, 0-3. */ private fun addControllerPerGameSettings( - sl: ArrayList<SettingsItem>, - controller: EmulatedController, - controllerNumber: Int + sl: ArrayList<SettingsItem>, controller: EmulatedController, controllerNumber: Int ) { val profiles = ProfileDialogPresenter(menuTag).getProfileNames(false) val profileKey = controller.getProfileKey() + "Profile" + (controllerNumber + 1) @@ -2648,15 +2726,11 @@ class SettingsFragmentPresenter( * @param controller The controller to add settings for. */ private fun addControllerMetaSettings( - sl: ArrayList<SettingsItem>, - controller: EmulatedController + sl: ArrayList<SettingsItem>, controller: EmulatedController ) { sl.add( InputDeviceSetting( - context, - R.string.input_device, - 0, - controller + context, R.string.input_device, 0, controller ) ) @@ -2698,12 +2772,7 @@ class SettingsFragmentPresenter( ) { clearControllerSettings(controller) }) sl.add( RunRunnable( - context, - R.string.input_profiles, - 0, - 0, - 0, - true + context, R.string.input_profiles, 0, 0, 0, true ) { fragmentView.showDialogFragment(ProfileDialog.create(menuTag)) }) updateOldControllerSettingsWarningVisibility(controller) @@ -2714,14 +2783,18 @@ class SettingsFragmentPresenter( * * @param sl The list to place controller settings into. * @param controller The controller to add settings for. + * @param controllerNumber The zero-based controller number. * @param groupTypeFilter If this is non-null, only groups whose types match this are considered. */ private fun addControllerMappingSettings( - sl: ArrayList<SettingsItem>, - controller: EmulatedController, - groupTypeFilter: Set<Int>? + sl: ArrayList<SettingsItem>, + controller: EmulatedController, + controllerNumber: Int, + groupTypeFilter: Set<Int>? ) { - addContainerMappingSettings(sl, controller, controller, groupTypeFilter) + addContainerMappingSettings( + sl, controller, controller, controllerNumber, groupTypeFilter + ) } /** @@ -2730,12 +2803,14 @@ class SettingsFragmentPresenter( * @param sl The list to place controller settings into. * @param controller The encompassing controller. * @param container The container of control groups to add settings for. + * @param controllerNumber The zero-based controller number. * @param groupTypeFilter If this is non-null, only groups whose types match this are considered. */ private fun addContainerMappingSettings( sl: ArrayList<SettingsItem>, controller: EmulatedController, container: ControlGroupContainer, + controllerNumber: Int, groupTypeFilter: Set<Int>? ) { updateOldControllerSettingsWarningVisibility(controller) @@ -2751,10 +2826,7 @@ class SettingsFragmentPresenter( if (group.getDefaultEnabledValue() != ControlGroup.DEFAULT_ENABLED_ALWAYS) { sl.add( SwitchSetting( - context, - ControlGroupEnabledSetting(group), - R.string.enabled, - 0 + context, ControlGroupEnabledSetting(group), R.string.enabled, 0 ) ) } @@ -2768,8 +2840,11 @@ class SettingsFragmentPresenter( val attachmentSetting = group.getAttachmentSetting() sl.add( SingleChoiceSetting( - context, InputMappingIntSetting(attachmentSetting), - R.string.wiimote_extensions, 0, R.array.wiimoteExtensionsEntries, + context, + InputMappingIntSetting(attachmentSetting), + R.string.wiimote_extensions, + 0, + R.array.wiimoteExtensionsEntries, R.array.wiimoteExtensionsValues, MenuTag.getWiimoteExtensionMenuTag(controllerNumber) ) @@ -2813,10 +2888,14 @@ class SettingsFragmentPresenter( } private fun updateOldControllerSettingsWarningVisibility(controller: EmulatedController) { + if (!shouldUpdateWarnings) { + return + } + val defaultDevice = controller.getDefaultDevice() - hasOldControllerSettings = defaultDevice.startsWith("Android/") && - defaultDevice.endsWith("/Touchscreen") + hasOldControllerSettings = + defaultDevice.startsWith("Android/") && defaultDevice.endsWith("/Touchscreen") fragmentView.setOldControllerSettingsWarningVisibility(hasOldControllerSettings) } @@ -2836,10 +2915,7 @@ class SettingsFragmentPresenter( for (logType in NativeLibrary.GetLogTypeNames()) { AdHocBooleanSetting( - Settings.FILE_LOGGER, - Settings.SECTION_LOGGER_LOGS, - logType.first, - false + Settings.FILE_LOGGER, Settings.SECTION_LOGGER_LOGS, logType.first, false ).setBoolean(settings!!, value) } @@ -2851,8 +2927,7 @@ class SettingsFragmentPresenter( fragmentView.fragmentActivity, R.string.wii_converting, 0, - { context.resources.getString(if (f.get()) R.string.wii_convert_success else R.string.wii_convert_failure) } - ) + { context.resources.getString(if (f.get()) R.string.wii_convert_success else R.string.wii_convert_failure) }) } fun installDriver(uri: Uri) { @@ -2884,9 +2959,8 @@ class SettingsFragmentPresenter( GpuDriverHelper.uninstallDriver() withContext(Dispatchers.Main) { with(this@SettingsFragmentPresenter) { - this.gpuDriver = - GpuDriverHelper.getInstalledDriverMetadata() - ?: GpuDriverHelper.getSystemDriverMetadata(context.applicationContext) + this.gpuDriver = GpuDriverHelper.getInstalledDriverMetadata() + ?: GpuDriverHelper.getSystemDriverMetadata(context.applicationContext) this.libNameSetting.setString(this.settings!!, "") } fragmentView.onDriverUninstallDone() @@ -2898,6 +2972,24 @@ class SettingsFragmentPresenter( const val ARG_CONTROLLER_TYPE = "controller_type" const val ARG_SERIALPORT1_TYPE = "serialport1_type" + private data class SearchableSetting( + val name: String, + val description: String, + val menuTag: MenuTag, + val navigationExtras: Bundle?, + val position: Int, + val normalizedName: String, + val normalizedCategory: String, + val normalizedSearchText: String + ) + + private data class SearchableMenu( + val menuTag: MenuTag, + val category: String, + val navigationExtras: Bundle?, + val settings: List<SettingsItem> + ) + // Value obtained from LogLevel in Common/Logging/Log.h private fun getLogVerbosityEntries(): Int { // GetMaxLogLevel is effectively a constant, but we can't call it before loading diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentView.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentView.kt index c0b39400a7..4530792bee 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentView.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentView.kt @@ -2,6 +2,7 @@ package org.dolphinemu.dolphinemu.features.settings.ui +import android.os.Bundle import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle @@ -50,6 +51,12 @@ interface SettingsFragmentView { * @param menuKey Identifier for the settings group that should be shown. */ fun loadSubMenu(menuKey: MenuTag) + + /** + * Opens the settings screen containing a search result and scrolls to the result. + */ + fun loadSearchResult(menuKey: MenuTag, settingPosition: Int, extras: Bundle?) + fun showDialogFragment(fragment: DialogFragment) /** @@ -67,7 +74,7 @@ interface SettingsFragmentView { /** * Have the fragment tell the containing Activity that a Setting was modified. */ - fun onSettingChanged() + fun onSettingChanged(setting: SettingsItem? = null) /** * Refetches the values of all controller settings. @@ -96,6 +103,11 @@ interface SettingsFragmentView { fun hasMenuTagActionForValue(menuTag: MenuTag, value: Int): Boolean /** + * Returns the arguments used when opening a navigable setting's associated screen. + */ + fun getMenuTagActionExtras(menuTag: MenuTag, value: Int): Bundle? + + /** * Controls whether the input mapping dialog should detect inputs from all devices, * not just the device configured for the controller. */ diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SettingViewHolder.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SettingViewHolder.kt index dd5d4c97f0..77414fedce 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SettingViewHolder.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SettingViewHolder.kt @@ -2,14 +2,20 @@ package org.dolphinemu.dolphinemu.features.settings.ui.viewholder +import android.animation.ValueAnimator import android.content.DialogInterface import android.graphics.Paint import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable import android.view.View import android.view.View.OnLongClickListener +import android.view.animation.DecelerateInterpolator import android.widget.TextView import android.widget.Toast import androidx.lifecycle.LifecycleOwner +import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.dolphinemu.dolphinemu.DolphinApplication import org.dolphinemu.dolphinemu.R @@ -21,6 +27,9 @@ abstract class SettingViewHolder(itemView: View, protected val adapter: Settings LifecycleViewHolder(itemView, adapter.getFragmentLifecycle()), LifecycleOwner, View.OnClickListener, OnLongClickListener { + private val defaultBackground: Drawable? = itemView.background + private var searchResultHighlightAnimator: ValueAnimator? = null + init { itemView.setOnClickListener(this) itemView.setOnLongClickListener(this) @@ -39,6 +48,35 @@ abstract class SettingViewHolder(itemView: View, protected val adapter: Settings } } + fun highlightSearchResult() { + clearSearchResultHighlight() + + val highlight = ColorDrawable( + MaterialColors.getColor( + itemView, com.google.android.material.R.attr.colorSecondaryContainer + ) + ).apply { alpha = 0 } + itemView.background = if (defaultBackground == null) { + highlight + } else { + LayerDrawable(arrayOf(highlight, defaultBackground)) + } + searchResultHighlightAnimator = ValueAnimator.ofInt( + 0, SEARCH_RESULT_HIGHLIGHT_MAX_ALPHA + ).apply { + duration = SEARCH_RESULT_HIGHLIGHT_FADE_IN_DURATION_MS + interpolator = DecelerateInterpolator() + addUpdateListener { highlight.alpha = it.animatedValue as Int } + start() + } + } + + fun clearSearchResultHighlight() { + searchResultHighlightAnimator?.cancel() + searchResultHighlightAnimator = null + itemView.background = defaultBackground + } + /** * Called by the adapter to set this ViewHolder's child views to display the list item * it must now represent. @@ -102,4 +140,9 @@ abstract class SettingViewHolder(itemView: View, protected val adapter: Settings Toast.LENGTH_SHORT ).show() } + + companion object { + private const val SEARCH_RESULT_HIGHLIGHT_FADE_IN_DURATION_MS = 180L + private const val SEARCH_RESULT_HIGHLIGHT_MAX_ALPHA = 255 + } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SettingsSearchResultViewHolder.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SettingsSearchResultViewHolder.kt new file mode 100644 index 0000000000..9471d6bf43 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SettingsSearchResultViewHolder.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.settings.ui.viewholder + +import android.view.View +import org.dolphinemu.dolphinemu.databinding.ListItemSearchResultBinding +import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem +import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsSearchResult +import org.dolphinemu.dolphinemu.features.settings.ui.SettingsAdapter + +class SettingsSearchResultViewHolder( + private val binding: ListItemSearchResultBinding, adapter: SettingsAdapter +) : SettingViewHolder(binding.root, adapter) { + private lateinit var result: SettingsSearchResult + + override val item: SettingsItem + get() = result + + override fun bind(item: SettingsItem) { + result = item as SettingsSearchResult + binding.textSettingName.text = item.name + binding.textSettingDescription.text = item.description + } + + override fun onClick(clicked: View) { + adapter.onSearchResultClick(result) + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/skylanders/ui/SkylanderSlotAdapter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/skylanders/ui/SkylanderSlotAdapter.kt index ed3dde3e40..4c23eb4a3c 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/skylanders/ui/SkylanderSlotAdapter.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/skylanders/ui/SkylanderSlotAdapter.kt @@ -47,7 +47,7 @@ class SkylanderSlotAdapter( holder.binding.buttonLoadFigure.setOnClickListener { activity.setSkylanderData(0, 0, "", position) - activity.requestSkylanderFile.launch("*/*") + activity.requestSkylanderFile.launch(arrayOf("*/*")) } val inflater = LayoutInflater.from(activity) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt index a005995bdb..a0215d4cd8 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt @@ -165,6 +165,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { runWhenSurfaceIsValid = true } + fun resetEmulation() { + Log.debug("[EmulationFragment] Resetting emulation.") + NativeLibrary.ResetEmulation() + } + fun stopEmulation() { Log.debug("[EmulationFragment] Stopping emulation.") NativeLibrary.StopEmulation() diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/MenuFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/MenuFragment.kt index 6bf9c7c147..ee4bb50e3f 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/MenuFragment.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/MenuFragment.kt @@ -76,6 +76,7 @@ class MenuFragment : Fragment(), View.OnClickListener { button.setOnClickListener(this) } + binding.menuReset.setOnClickListener(this) binding.menuExit.setOnClickListener(this) val title = requireArguments().getString(KEY_TITLE, null) @@ -199,6 +200,7 @@ class MenuFragment : Fragment(), View.OnClickListener { EmulationActivity.MENU_ACTION_CHANGE_DISC ) buttonsActionsMap.append(R.id.menu_exit, EmulationActivity.MENU_ACTION_EXIT) + buttonsActionsMap.append(R.id.menu_reset, EmulationActivity.MENU_ACTION_RESET) buttonsActionsMap.append(R.id.menu_settings, EmulationActivity.MENU_ACTION_SETTINGS) buttonsActionsMap.append(R.id.menu_skylanders, EmulationActivity.MENU_ACTION_SKYLANDERS) buttonsActionsMap.append( diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt index 4aa7e2055a..f3d0ac1fce 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -89,10 +90,6 @@ fun PreviewTheme( private fun Context.toDolphinColorScheme(isDark: Boolean): ColorScheme { fun attr(@AttrRes attr: Int) = Color(MaterialColors.getColor(this, attr, 0)) - val background = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground)).use { - Color(it.getColor(0, 0)) - } - return if (isDark) { darkColorScheme( primary = attr(AppCompatR.attr.colorPrimary), @@ -111,7 +108,7 @@ private fun Context.toDolphinColorScheme(isDark: Boolean): ColorScheme { onError = attr(MaterialR.attr.colorOnError), errorContainer = attr(MaterialR.attr.colorErrorContainer), onErrorContainer = attr(MaterialR.attr.colorOnErrorContainer), - background = background, + background = attr(android.R.attr.colorBackground), onBackground = attr(MaterialR.attr.colorOnBackground), surface = attr(MaterialR.attr.colorSurface), onSurface = attr(MaterialR.attr.colorOnSurface), @@ -140,7 +137,7 @@ private fun Context.toDolphinColorScheme(isDark: Boolean): ColorScheme { onError = attr(MaterialR.attr.colorOnError), errorContainer = attr(MaterialR.attr.colorErrorContainer), onErrorContainer = attr(MaterialR.attr.colorOnErrorContainer), - background = background, + background = attr(android.R.attr.colorBackground), onBackground = attr(MaterialR.attr.colorOnBackground), surface = attr(MaterialR.attr.colorSurface), onSurface = attr(MaterialR.attr.colorOnSurface), @@ -160,6 +157,7 @@ fun DolphinScaffold( title: @Composable () -> Unit, navigationIcon: @Composable () -> Unit, modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, floatingActionButton: @Composable () -> Unit = {}, content: @Composable (PaddingValues) -> Unit, ) { @@ -179,11 +177,13 @@ fun DolphinScaffold( TopAppBar( title = title, navigationIcon = navigationIcon, + actions = actions, ) } else { MediumTopAppBar( title = title, navigationIcon = navigationIcon, + actions = actions, scrollBehavior = topAppBarScrollBehavior, ) } @@ -257,7 +257,7 @@ fun OutlinedBox( if (onClick != null) { Box( modifier = Modifier - .fillMaxSize() + .matchParentSize() .clip(MaterialTheme.shapes.extraSmall) .clickable( interactionSource = interactionSource, diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThemeHelper.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThemeHelper.kt index 2aa2122ad5..8b41fc6c55 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThemeHelper.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/ThemeHelper.kt @@ -1,20 +1,20 @@ package org.dolphinemu.dolphinemu.utils -import androidx.appcompat.app.AppCompatActivity -import org.dolphinemu.dolphinemu.R +import android.content.res.Configuration import android.os.Build -import androidx.core.content.ContextCompat +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat -import org.dolphinemu.dolphinemu.ui.main.ThemeProvider -import android.content.res.Configuration -import com.google.android.material.appbar.MaterialToolbar +import androidx.core.view.WindowInsetsControllerCompat +import androidx.preference.PreferenceManager import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.elevation.ElevationOverlayProvider +import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.color.MaterialColors -import androidx.annotation.ColorInt -import androidx.preference.PreferenceManager +import com.google.android.material.elevation.ElevationOverlayProvider +import org.dolphinemu.dolphinemu.R +import org.dolphinemu.dolphinemu.ui.main.ThemeProvider object ThemeHelper { @@ -52,8 +52,7 @@ object ThemeHelper { .getInt(CURRENT_THEME_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) activity.delegate.localNightMode = themeMode val windowController = WindowCompat.getInsetsController( - activity.window, - activity.window.decorView + activity.window, activity.window.decorView ) val systemReportedThemeMode = activity.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK @@ -62,6 +61,7 @@ object ThemeHelper { Configuration.UI_MODE_NIGHT_NO -> setLightModeSystemBars(windowController) Configuration.UI_MODE_NIGHT_YES -> setDarkModeSystemBars(windowController) } + AppCompatDelegate.MODE_NIGHT_NO -> setLightModeSystemBars(windowController) AppCompatDelegate.MODE_NIGHT_YES -> setDarkModeSystemBars(windowController) } @@ -83,66 +83,50 @@ object ThemeHelper { @JvmStatic fun saveTheme(activity: AppCompatActivity, themeValue: Int) { - PreferenceManager.getDefaultSharedPreferences(activity.applicationContext) - .edit() - .putInt(CURRENT_THEME, themeValue) - .apply() + PreferenceManager.getDefaultSharedPreferences(activity.applicationContext).edit() + .putInt(CURRENT_THEME, themeValue).apply() activity.recreate() } @JvmStatic fun deleteThemeKey(activity: AppCompatActivity) { - PreferenceManager.getDefaultSharedPreferences(activity.applicationContext) - .edit() - .remove(CURRENT_THEME) - .apply() + PreferenceManager.getDefaultSharedPreferences(activity.applicationContext).edit() + .remove(CURRENT_THEME).apply() activity.recreate() } @JvmStatic fun saveThemeMode(activity: AppCompatActivity, themeModeValue: Int) { - PreferenceManager.getDefaultSharedPreferences(activity.applicationContext) - .edit() - .putInt(CURRENT_THEME_MODE, themeModeValue) - .apply() + PreferenceManager.getDefaultSharedPreferences(activity.applicationContext).edit() + .putInt(CURRENT_THEME_MODE, themeModeValue).apply() setThemeMode(activity) } @JvmStatic fun deleteThemeModeKey(activity: AppCompatActivity) { - PreferenceManager.getDefaultSharedPreferences(activity.applicationContext) - .edit() - .remove(CURRENT_THEME_MODE) - .apply() + PreferenceManager.getDefaultSharedPreferences(activity.applicationContext).edit() + .remove(CURRENT_THEME_MODE).apply() setThemeMode(activity) } @JvmStatic fun saveBackgroundSetting(activity: AppCompatActivity, backgroundValue: Boolean) { - PreferenceManager.getDefaultSharedPreferences(activity.applicationContext) - .edit() - .putBoolean(USE_BLACK_BACKGROUNDS, backgroundValue) - .apply() + PreferenceManager.getDefaultSharedPreferences(activity.applicationContext).edit() + .putBoolean(USE_BLACK_BACKGROUNDS, backgroundValue).apply() activity.recreate() } @JvmStatic fun deleteBackgroundSetting(activity: AppCompatActivity) { - PreferenceManager.getDefaultSharedPreferences(activity.applicationContext) - .edit() - .remove(USE_BLACK_BACKGROUNDS) - .apply() + PreferenceManager.getDefaultSharedPreferences(activity.applicationContext).edit() + .remove(USE_BLACK_BACKGROUNDS).apply() activity.recreate() } @JvmStatic fun resetThemePreferences(activity: AppCompatActivity, applyImmediately: Boolean = false) { - PreferenceManager.getDefaultSharedPreferences(activity.applicationContext) - .edit() - .remove(CURRENT_THEME) - .remove(CURRENT_THEME_MODE) - .remove(USE_BLACK_BACKGROUNDS) - .apply() + PreferenceManager.getDefaultSharedPreferences(activity.applicationContext).edit() + .remove(CURRENT_THEME).remove(CURRENT_THEME_MODE).remove(USE_BLACK_BACKGROUNDS).apply() activity.delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM activity.delegate.applyDayNight() if (applyImmediately) { @@ -170,7 +154,7 @@ object ThemeHelper { activity: AppCompatActivity, toolbar: MaterialToolbar, appBarLayout: AppBarLayout ) { appBarLayout.addOnOffsetChangedListener { layout: AppBarLayout, verticalOffset: Int -> - if (-verticalOffset >= layout.totalScrollRange / 2) { + if (layout.totalScrollRange > 0 && -verticalOffset >= layout.totalScrollRange / 2) { @ColorInt val color = ElevationOverlayProvider(appBarLayout.context).compositeOverlay( MaterialColors.getColor(appBarLayout, R.attr.colorSurface), @@ -180,8 +164,7 @@ object ThemeHelper { setStatusBarColor(activity, color) } else { @ColorInt val statusBarColor = ContextCompat.getColor( - activity.applicationContext, - android.R.color.transparent + activity.applicationContext, android.R.color.transparent ) @ColorInt val appBarColor = MaterialColors.getColor(toolbar, R.attr.colorSurface) toolbar.setBackgroundColor(appBarColor) @@ -198,8 +181,7 @@ object ThemeHelper { setStatusBarColor(activity, color) } else { @ColorInt val statusBarColor = ContextCompat.getColor( - activity.applicationContext, - android.R.color.transparent + activity.applicationContext, android.R.color.transparent ) setStatusBarColor(activity, statusBarColor) } |
