diff options
| author | JosJuice <josjuice@gmail.com> | 2026-08-29 11:19:47 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-29 11:19:47 +0200 |
| commit | 30c9669d7b1d3bd2d1d2d413dfcabfa1decc7b58 (patch) | |
| tree | 065b2ef705972a3519fd5174ed094350533126d9 /Source | |
| parent | 9b50b88d2e48435652175d188c4da38fcb538a98 (diff) | |
| parent | e6f5516ad7a6b019db57b981625c5e8fbb670d4a (diff) | |
Merge pull request #14750 from tom-pratt/netplay-controller-mapping
Android: Netplay controller mapping
Diffstat (limited to 'Source')
8 files changed, 424 insertions, 40 deletions
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..d44e70ba09 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 @@ -185,6 +192,11 @@ class NetplaySession( 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 @@ -278,6 +290,14 @@ class NetplaySession( 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 +324,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 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/NetplayViewModel.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt index 0764448824..1e3aea26a2 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 @@ -20,6 +20,7 @@ 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 @@ -59,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()) @@ -168,6 +172,18 @@ 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) + ) + } + private fun getLocalIp(): JoinAddress { val localIp = networkHelper.getLocalIpString() ?: return JoinAddress.Unknown { _joinAddresses.value += JoinInfoType.LOCAL to getLocalIp() } 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 28e0e4ca16..ee1098956d 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 @@ -73,6 +73,9 @@ 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, ) } } 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 7df8ea35ee..aebac3f5bc 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,6 +4,7 @@ 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 @@ -20,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 @@ -38,7 +40,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Share @@ -79,10 +80,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layout -import androidx.compose.ui.layout.onGloballyPositioned 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 @@ -92,6 +91,7 @@ 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.unit.sp import androidx.window.core.layout.WindowSizeClass @@ -100,6 +100,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 @@ -149,6 +150,9 @@ fun NetplayScreen( saveTransferProgress: SaveTransferProgress?, gameDigestProgress: GameDigestProgress?, joinAddresses: Map<JoinInfoType, JoinAddress>, + controllerMapping: ControllerMapping, + onGamecubePortChanged: (port: Int, player: Player?) -> Unit, + onWiiRemoteChanged: (port: Int, player: Player?) -> Unit, ) { val scrollState = rememberScrollState() @@ -179,6 +183,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) } @@ -202,6 +207,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, @@ -229,6 +239,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, @@ -368,6 +383,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, @@ -407,6 +427,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, @@ -441,6 +466,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, @@ -473,7 +503,6 @@ private fun LandscapeContent( .fillMaxHeight() ) - val scrollState = rememberScrollState() Column( modifier = Modifier .weight(1f) @@ -492,6 +521,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, @@ -513,6 +547,7 @@ private fun LandscapeContent( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PlayersAndSettings( game: String, @@ -521,6 +556,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, @@ -558,25 +598,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() @@ -960,6 +990,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. @@ -972,7 +1060,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), @@ -984,23 +1071,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, @@ -1017,6 +1102,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( @@ -1473,6 +1702,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, 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 074856bad8..e3ab05f3ab 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 @@ -253,7 +253,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/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index ccd5ee3b70..2204f4380f 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1031,6 +1031,11 @@ It can efficiently compress both junk data and encrypted Wii data. <string name="netplay_players_name">Name</string> <string name="netplay_players_ping">Ping</string> <string name="netplay_players_mapping">Mapping</string> + <string name="netplay_controllers_gamecube">GameCube</string> + <string name="netplay_controllers_wii_remotes">Wii Remotes</string> + <string name="netplay_controllers_port">Port %1$d</string> + <string name="netplay_controllers_remote">Remote %1$d</string> + <string name="netplay_controllers_none">None</string> <string name="netplay_network_mode_label">Input mode</string> <string name="netplay_network_mode_fair_input_delay">Fair Input Delay</string> <string name="netplay_network_mode_host_input_authority">Host Input Authority</string> diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index d6e24d250b..16804b808b 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -1,8 +1,11 @@ // Copyright 2026 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include <array> #include <memory> #include <string> +#include <tuple> +#include <type_traits> #include <vector> #include <jni.h> @@ -38,6 +41,30 @@ static NetPlay::NetPlayServer* GetServerPointer(JNIEnv* env, jobject obj) env->GetLongField(obj, IDCache::GetNetPlayServerPointer())); } +static jintArray PadMappingArrayToJIntArray(JNIEnv* env, const NetPlay::PadMappingArray& mapping) +{ + const jsize size = static_cast<jsize>(mapping.size()); + jintArray jmapping = env->NewIntArray(size); + std::array<jint, std::tuple_size_v<std::decay_t<decltype(mapping)>>> values; + for (size_t i = 0; i < mapping.size(); i++) + values[i] = static_cast<jint>(mapping[i]); + env->SetIntArrayRegion(jmapping, 0, size, values.data()); + return jmapping; +} + +static bool JIntArrayToPadMappingArray(JNIEnv* env, jintArray jmapping, + NetPlay::PadMappingArray* mapping) +{ + if (env->GetArrayLength(jmapping) != static_cast<jsize>(mapping->size())) + return false; + + std::array<jint, std::tuple_size_v<std::decay_t<decltype(*mapping)>>> values; + env->GetIntArrayRegion(jmapping, 0, static_cast<jsize>(values.size()), values.data()); + for (size_t i = 0; i < mapping->size(); i++) + (*mapping)[i] = static_cast<NetPlay::PlayerId>(values[i]); + return true; +} + extern "C" { JNIEXPORT void JNICALL @@ -224,6 +251,54 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReconnectTr Common::g_TraversalClient->ReconnectToServer(); } +JNIEXPORT jintArray JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeGetPadMapping(JNIEnv* env, + jobject obj) +{ + auto* server = GetServerPointer(env, obj); + if (!server) + return env->NewIntArray(0); + + return PadMappingArrayToJIntArray(env, server->GetPadMapping()); +} + +JNIEXPORT jintArray JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeGetWiimoteMapping(JNIEnv* env, + jobject obj) +{ + auto* server = GetServerPointer(env, obj); + if (!server) + return env->NewIntArray(0); + + return PadMappingArrayToJIntArray(env, server->GetWiimoteMapping()); +} + +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeSetPadMapping( + JNIEnv* env, jobject obj, jintArray jmapping) +{ + auto* server = GetServerPointer(env, obj); + if (!server) + return; + + NetPlay::PadMappingArray mapping = server->GetPadMapping(); + if (JIntArrayToPadMappingArray(env, jmapping, &mapping)) + server->SetPadMapping(mapping); +} + +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeSetWiimoteMapping( + JNIEnv* env, jobject obj, jintArray jmapping) +{ + auto* server = GetServerPointer(env, obj); + if (!server) + return; + + NetPlay::PadMappingArray mapping = server->GetWiimoteMapping(); + if (JIntArrayToPadMappingArray(env, jmapping, &mapping)) + server->SetWiimoteMapping(mapping); +} + JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseUICallbacks( JNIEnv*, jobject, jlong pointer) |
