summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTom Pratt <tom.pratt@outlook.com>2026-04-10 11:30:59 +0200
committerTom Pratt <tompratt@squareup.com>2026-05-19 12:02:55 +0200
commitd9e2725e85eafecd9dac67dfdec8a3825d6933e1 (patch)
tree312838a9555a3d7aac60357601ff3335eb7628e1
parentaf2fda564954b975115b212221aa13f0308fe406 (diff)
Implement player list
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt19
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt6
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt13
-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.kt119
-rw-r--r--Source/Android/jni/AndroidCommon/IDCache.cpp27
-rw-r--r--Source/Android/jni/AndroidCommon/IDCache.h4
-rw-r--r--Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp36
8 files changed, 223 insertions, 4 deletions
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<String>(Channel.BUFFERED)
val connectionErrors = _connectionErrors.receiveAsFlow()
+ private val _players = MutableSharedFlow<List<Player>>(
+ 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<Player>) {
+ _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<Unit>(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<Player>,
) {
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<List<String>>,
+ 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<jclass>(env->NewGlobalRef(netplay_player_class));
+ s_netplay_player_constructor = env->GetMethodID(netplay_player_class, "<init>", "(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<jclass>(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<B
void NetPlayUICallbacks::StopGame() {}
bool NetPlayUICallbacks::IsHosting() const { return false; }
-void NetPlayUICallbacks::Update() {}
+
+void NetPlayUICallbacks::Update()
+{
+ JNIEnv* env = IDCache::GetEnvForThread();
+ auto* client = reinterpret_cast<NetPlay::NetPlayClient*>(
+ env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetPlayClientPointer()));
+ if (!client)
+ return;
+
+ const std::vector<const NetPlay::Player*> players = client->GetPlayers();
+
+ jobjectArray player_array =
+ env->NewObjectArray(static_cast<jsize>(players.size()), IDCache::GetNetplayPlayerClass(), nullptr);
+
+ for (jsize i = 0; i < static_cast<jsize>(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<jint>(player->pid),
+ ToJString(env, player->name),
+ ToJString(env, player->revision),
+ static_cast<jint>(player->ping),
+ static_cast<jboolean>(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,