diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt index 9b19c9c2fd..ceda995051 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt @@ -5,11 +5,17 @@ package org.dolphinemu.dolphinemu.features.netplay import androidx.annotation.Keep import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType +import org.dolphinemu.dolphinemu.features.netplay.model.Player object Netplay { @Keep @@ -27,6 +33,12 @@ object Netplay { private val _connectionErrors = Channel(Channel.BUFFERED) val connectionErrors = _connectionErrors.receiveAsFlow() + private val _players = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val players = _players.asSharedFlow().distinctUntilChanged() + suspend fun join(): Boolean = withContext(Dispatchers.IO) { netPlayClientPointer = Join() val isConnected = netPlayClientPointer != 0L && isClientConnected() @@ -48,6 +60,7 @@ object Netplay { releaseNetplayClient() } + @OptIn(ExperimentalCoroutinesApi::class) private fun releaseNetplayClient() { if (netPlayClientPointer != 0L) { ReleaseNetplayClient() @@ -55,6 +68,7 @@ object Netplay { } _launchGame.flush() _connectionErrors.flush() + _players.resetReplayCache() } @JvmStatic @@ -79,6 +93,11 @@ object Netplay { _connectionErrors.trySend(message) } + @JvmStatic + fun onUpdate(players: Array) { + _players.tryEmit(players.toList()) + } + // Settings @JvmStatic 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 cf941aa01f..942dc0d0dc 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 @@ -3,11 +3,14 @@ package org.dolphinemu.dolphinemu.features.netplay.model import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.CONFLATED +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.features.netplay.Netplay @@ -17,6 +20,9 @@ class NetplayViewModel : ViewModel() { private val _goBack = Channel(CONFLATED) val goBack = _goBack.receiveAsFlow() + val players = Netplay.players + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + init { if (!Netplay.isClientConnected()) { _goBack.trySend(Unit) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt new file mode 100644 index 0000000000..70c228d1c9 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt @@ -0,0 +1,13 @@ +// Copyright 2003 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.netplay.model + +data class Player( + val pid: Int, + val name: String, + val revision: String, + val ping: Int, + val isHost: Boolean, + val mapping: String, +) \ No newline at end of file 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 7f199bd45e..5a91ae5b91 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 @@ -8,6 +8,7 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.collectAsState import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.flowWithLifecycle @@ -15,7 +16,6 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.dolphinemu.dolphinemu.activities.EmulationActivity -import org.dolphinemu.dolphinemu.features.netplay.Netplay import org.dolphinemu.dolphinemu.features.netplay.model.NetplayViewModel import org.dolphinemu.dolphinemu.ui.main.ThemeProvider import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme @@ -44,6 +44,7 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { DolphinTheme { NetplayScreen( onBackClicked = { finish() }, + players = viewModel.players.collectAsState().value, ) } } 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 de3b89d6c1..e4089dae45 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 @@ -2,23 +2,46 @@ package org.dolphinemu.dolphinemu.features.netplay.ui +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import org.dolphinemu.dolphinemu.R +import org.dolphinemu.dolphinemu.features.netplay.model.Player +import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme @OptIn(ExperimentalMaterial3Api::class) @Composable fun NetplayScreen( onBackClicked: () -> Unit, + players: List, ) { Scaffold( topBar = { @@ -34,7 +57,81 @@ fun NetplayScreen( }, ) }, - ) { _ -> } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + .padding(horizontal = DolphinTheme.scaffoldPadding) + ) { + PlayersTable( + rows = buildList { + add(listOf("Player", "Ping", "Mapping")) + addAll(players.map { listOf(it.name, it.ping.toString(), it.mapping) }) + }, + 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. + * The first row is treated as the column titles. + */ +@Composable +private fun PlayersTable( + rows: List>, + modifier: Modifier = Modifier, +) { + 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), + modifier = modifier + ) { + rows.forEachIndexed { rowIndex, row -> + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + 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 + } + ) + .onGloballyPositioned { coordinates -> + val width = coordinates.size.width + if (width > maxWidths[itemIndex].intValue) { + maxWidths[itemIndex].intValue = width + } + } + ) { + Text( + text = text, + fontWeight = if (rowIndex == 0) FontWeight.Medium else FontWeight.Normal, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + if (rowIndex == 0) { + HorizontalDivider() + } + } + } } @Preview @@ -42,5 +139,23 @@ fun NetplayScreen( private fun NetplayScreenPreview() { NetplayScreen( onBackClicked = {}, + players = listOf( + Player( + pid = 1, + name = "Player 1", + revision = "123", + ping = 2, + isHost = true, + mapping = "m1" + ), + Player( + pid = 2, + name = "Player 2", + revision = "123", + ping = 23, + isHost = false, + mapping = "m2" + ), + ), ) -} \ No newline at end of file +} diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 8f74425efa..b45849bf5d 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -33,6 +33,10 @@ static jfieldID s_net_play_client_pointer; static jfieldID s_netplay_boot_session_data_pointer; static jmethodID s_netplay_on_boot_game; static jmethodID s_netplay_on_connection_error; +static jmethodID s_netplay_update; + +static jclass s_netplay_player_class; +static jmethodID s_netplay_player_constructor; static jclass s_analytics_class; static jmethodID s_get_analytics_value; @@ -264,6 +268,21 @@ jmethodID GetNetplayOnConnectionError() return s_netplay_on_connection_error; } +jmethodID GetNetplayUpdate() +{ + return s_netplay_update; +} + +jclass GetNetplayPlayerClass() +{ + return s_netplay_player_class; +} + +jmethodID GetNetplayPlayerConstructor() +{ + return s_netplay_player_constructor; +} + jclass GetPairClass() { return s_pair_class; @@ -675,8 +694,15 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) s_netplay_boot_session_data_pointer = env->GetStaticFieldID(netplay_class, "bootSessionDataPointer", "J"); s_netplay_on_boot_game = env->GetStaticMethodID(netplay_class, "onBootGame", "(Ljava/lang/String;J)V"); s_netplay_on_connection_error = env->GetStaticMethodID(netplay_class, "onConnectionError", "(Ljava/lang/String;)V"); + s_netplay_update = env->GetStaticMethodID(netplay_class, "onUpdate", "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V"); env->DeleteLocalRef(netplay_class); + const jclass netplay_player_class = + env->FindClass("org/dolphinemu/dolphinemu/features/netplay/model/Player"); + s_netplay_player_class = reinterpret_cast(env->NewGlobalRef(netplay_player_class)); + s_netplay_player_constructor = env->GetMethodID(netplay_player_class, "", "(ILjava/lang/String;Ljava/lang/String;IZLjava/lang/String;)V"); + env->DeleteLocalRef(netplay_player_class); + const jclass analytics_class = env->FindClass("org/dolphinemu/dolphinemu/utils/Analytics"); s_analytics_class = reinterpret_cast(env->NewGlobalRef(analytics_class)); s_get_analytics_value = env->GetStaticMethodID(s_analytics_class, "getValue", @@ -892,6 +918,7 @@ JNIEXPORT void JNI_OnUnload(JavaVM* vm, void* reserved) env->DeleteGlobalRef(s_game_file_cache_class); env->DeleteGlobalRef(s_game_file_cache_manager_class); env->DeleteGlobalRef(s_netplay_class); + env->DeleteGlobalRef(s_netplay_player_class); env->DeleteGlobalRef(s_analytics_class); env->DeleteGlobalRef(s_pair_class); env->DeleteGlobalRef(s_hash_map_class); diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index d59d1ca9f0..0e393f919a 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -36,6 +36,10 @@ jfieldID GetNetPlayClientPointer(); jfieldID GetNetplayBootSessionDataPointer(); jmethodID GetNetplayOnBootGame(); jmethodID GetNetplayOnConnectionError(); +jmethodID GetNetplayUpdate(); + +jclass GetNetplayPlayerClass(); +jmethodID GetNetplayPlayerConstructor(); jclass GetPairClass(); jmethodID GetPairConstructor(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index baa38de18d..478052e36c 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -24,7 +24,41 @@ void NetPlayUICallbacks::BootGame(const std::string& filename, std::unique_ptr( + env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetPlayClientPointer())); + if (!client) + return; + + const std::vector players = client->GetPlayers(); + + jobjectArray player_array = + env->NewObjectArray(static_cast(players.size()), IDCache::GetNetplayPlayerClass(), nullptr); + + for (jsize i = 0; i < static_cast(players.size()); i++) + { + const NetPlay::Player* player = players[i]; + const std::string mapping = NetPlay::GetPlayerMappingString( + player->pid, client->GetPadMapping(), client->GetGBAConfig(), client->GetWiimoteMapping()); + jobject player_obj = env->NewObject( + IDCache::GetNetplayPlayerClass(), IDCache::GetNetplayPlayerConstructor(), + static_cast(player->pid), + ToJString(env, player->name), + ToJString(env, player->revision), + static_cast(player->ping), + static_cast(player->IsHost()), + ToJString(env, mapping)); + env->SetObjectArrayElement(player_array, i, player_obj); + env->DeleteLocalRef(player_obj); + } + + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayUpdate(), player_array); + env->DeleteLocalRef(player_array); +} + void NetPlayUICallbacks::AppendChat(const std::string&) {} void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_identifier,