Implement player list

This commit is contained in:
Tom Pratt 2026-04-10 11:30:59 +02:00 committed by Tom Pratt
parent af2fda5649
commit d9e2725e85
8 changed files with 223 additions and 4 deletions

View File

@ -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

View File

@ -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)

View File

@ -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,
)

View File

@ -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,
)
}
}

View File

@ -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"
),
),
)
}
}

View File

@ -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);

View File

@ -36,6 +36,10 @@ jfieldID GetNetPlayClientPointer();
jfieldID GetNetplayBootSessionDataPointer();
jmethodID GetNetplayOnBootGame();
jmethodID GetNetplayOnConnectionError();
jmethodID GetNetplayUpdate();
jclass GetNetplayPlayerClass();
jmethodID GetNetplayPlayerConstructor();
jclass GetPairClass();
jmethodID GetPairConstructor();

View File

@ -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,