From 3236c634d9dfb9d382d1f27c2e380c341cb56ab7 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 23 Jul 2026 19:47:07 +0200 Subject: Add functionality to get and set controller mappings in Android --- .../dolphinemu/features/netplay/NetplaySession.kt | 31 ++++++++- .../features/netplay/model/ControllerMapping.kt | 24 +++++++ .../features/netplay/model/NetplayViewModel.kt | 16 +++++ Source/Android/jni/NetPlay/Netplay.cpp | 73 ++++++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ControllerMapping.kt (limited to 'Source/Android') 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( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val controllerMapping = _controllerMapping.asSharedFlow().distinctUntilChanged() + private val _chatMessages = MutableSharedFlow( 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) { - _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, + val wiiRemotes: List, +) { + 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 List.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/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index d6e24d250b..287f2cdd27 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -1,6 +1,7 @@ // Copyright 2026 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include #include #include #include @@ -38,6 +39,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(mapping.size()); + jintArray jmapping = env->NewIntArray(size); + std::array values; + for (size_t i = 0; i < mapping.size(); i++) + values[i] = static_cast(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(mapping->size())) + return false; + + std::array values; + env->GetIntArrayRegion(jmapping, 0, static_cast(values.size()), values.data()); + for (size_t i = 0; i < mapping->size(); i++) + (*mapping)[i] = static_cast(values[i]); + return true; +} + extern "C" { JNIEXPORT void JNICALL @@ -224,6 +249,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) -- cgit v1.2.3 From df4c394fc3456ab6af8ea841d9bb94f297f75e61 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 21 Jul 2026 18:45:44 +0200 Subject: Netplay PlayerTable fixes - Improve the column measurement logic so it updates the column width when text changes. - Use matchParentSize on the clickable target in OutlinedBox so that it is clickable when the OutlinedBox contains contents with dynamic height. --- .../features/netplay/ui/NetplayScreen.kt | 27 ++++++++++------------ .../dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt | 2 +- 2 files changed, 13 insertions(+), 16 deletions(-) (limited to 'Source/Android') 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..018a46a71b 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 @@ -972,7 +972,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 +983,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, 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..1aec1a0752 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 @@ -257,7 +257,7 @@ fun OutlinedBox( if (onClick != null) { Box( modifier = Modifier - .fillMaxSize() + .matchParentSize() .clip(MaterialTheme.shapes.extraSmall) .clickable( interactionSource = interactionSource, -- cgit v1.2.3 From e6f5516ad7a6b019db57b981625c5e8fbb670d4a Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 23 Jul 2026 16:35:54 +0200 Subject: Android controller mapping UI UI for assigning netplay players to gamecube ports and wii remotes. --- .../features/netplay/ui/NetplayActivity.kt | 3 + .../features/netplay/ui/NetplayScreen.kt | 281 +++++++++++++++++++-- Source/Android/app/src/main/res/values/strings.xml | 5 + Source/Android/jni/NetPlay/Netplay.cpp | 6 +- 4 files changed, 270 insertions(+), 25 deletions(-) (limited to 'Source/Android') 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 018a46a71b..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, + 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, + 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, + 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, + 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, + 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. @@ -1014,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, + 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, + players: List, + 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, + 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( @@ -1470,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/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 2f41de8fc7..dbabf07a07 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1030,6 +1030,11 @@ It can efficiently compress both junk data and encrypted Wii data. Name Ping Mapping + GameCube + Wii Remotes + Port %1$d + Remote %1$d + None Input mode Fair Input Delay Host Input Authority diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 287f2cdd27..16804b808b 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include @@ -43,7 +45,7 @@ static jintArray PadMappingArrayToJIntArray(JNIEnv* env, const NetPlay::PadMappi { const jsize size = static_cast(mapping.size()); jintArray jmapping = env->NewIntArray(size); - std::array values; + std::array>> values; for (size_t i = 0; i < mapping.size(); i++) values[i] = static_cast(mapping[i]); env->SetIntArrayRegion(jmapping, 0, size, values.data()); @@ -56,7 +58,7 @@ static bool JIntArrayToPadMappingArray(JNIEnv* env, jintArray jmapping, if (env->GetArrayLength(jmapping) != static_cast(mapping->size())) return false; - std::array values; + std::array>> values; env->GetIntArrayRegion(jmapping, 0, static_cast(values.size()), values.data()); for (size_t i = 0; i < mapping->size(); i++) (*mapping)[i] = static_cast(values[i]); -- cgit v1.2.3