summaryrefslogtreecommitdiff
path: root/Source
diff options
context:
space:
mode:
authorJosJuice <josjuice@gmail.com>2026-08-29 11:19:47 +0200
committerGitHub <noreply@github.com>2026-08-29 11:19:47 +0200
commit30c9669d7b1d3bd2d1d2d413dfcabfa1decc7b58 (patch)
tree065b2ef705972a3519fd5174ed094350533126d9 /Source
parent9b50b88d2e48435652175d188c4da38fcb538a98 (diff)
parente6f5516ad7a6b019db57b981625c5e8fbb670d4a (diff)
Merge pull request #14750 from tom-pratt/netplay-controller-mapping
Android: Netplay controller mapping
Diffstat (limited to 'Source')
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt31
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ControllerMapping.kt24
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt16
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt3
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt308
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt2
-rw-r--r--Source/Android/app/src/main/res/values/strings.xml5
-rw-r--r--Source/Android/jni/NetPlay/Netplay.cpp75
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)