Implement more NetPlayUICallbacks

Includes chat, game changes, pad buffer changes and host input authority. Merges them all into the chat window.
This commit is contained in:
Tom Pratt 2026-04-14 09:57:01 +02:00
parent d82a9242a1
commit a5bd27d731
10 changed files with 324 additions and 40 deletions

View File

@ -4,19 +4,31 @@
package org.dolphinemu.dolphinemu.features.netplay
import androidx.annotation.Keep
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
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.ConnectionType
import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage
import org.dolphinemu.dolphinemu.features.netplay.model.Player
//TODO add other necessary @Keep annotations
//TODO clear boot session data at appropriate time
object Netplay {
@Keep
private var netPlayClientPointer: Long = 0
@ -24,6 +36,8 @@ object Netplay {
@Keep
private var bootSessionDataPointer: Long = 0
private var sessionScope: CoroutineScope? = null
val isLaunching: Boolean
get() = bootSessionDataPointer != 0L
@ -33,13 +47,51 @@ object Netplay {
private val _connectionErrors = Channel<String>(Channel.BUFFERED)
val connectionErrors = _connectionErrors.receiveAsFlow()
private val _messages = MutableSharedFlow<List<NetplayMessage>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val messages = _messages.asSharedFlow()
private val _players = MutableSharedFlow<List<Player>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val players = _players.asSharedFlow().distinctUntilChanged()
private val _chatMessages = MutableSharedFlow<String>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val chatMessages = _chatMessages.asSharedFlow()
private val _game = MutableSharedFlow<String>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val game = _game.asSharedFlow()
private val _hostInputAuthorityEnabled = MutableSharedFlow<Boolean>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val hostInputAuthorityEnabled = _hostInputAuthorityEnabled.asSharedFlow()
private val _padBuffer = MutableSharedFlow<Int>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val padBuffer = _padBuffer.asSharedFlow()
suspend fun join(): Boolean = withContext(Dispatchers.IO) {
val scope = createSessionScope()
// Gather all messages that should appear in the chat window.
mergeMessages()
.runningFold(emptyList<NetplayMessage>()) { acc, msg -> listOf(msg) + acc }
.onEach { _messages.tryEmit(it) }
.launchIn(scope)
netPlayClientPointer = Join()
val isConnected = netPlayClientPointer != 0L && isClientConnected()
@ -62,13 +114,29 @@ object Netplay {
@OptIn(ExperimentalCoroutinesApi::class)
private fun releaseNetplayClient() {
sessionScope?.cancel()
sessionScope = null
if (netPlayClientPointer != 0L) {
ReleaseNetplayClient()
netPlayClientPointer = 0
}
_launchGame.flush()
_connectionErrors.flush()
_players.resetReplayCache()
_messages.resetReplayCache()
_chatMessages.resetReplayCache()
_game.resetReplayCache()
_hostInputAuthorityEnabled.resetReplayCache()
_padBuffer.resetReplayCache()
}
private fun createSessionScope(): CoroutineScope {
sessionScope?.cancel()
return CoroutineScope(SupervisorJob() + Dispatchers.IO).also {
sessionScope = it
}
}
@JvmStatic
@ -77,9 +145,19 @@ object Netplay {
@JvmStatic
external fun isClientConnected(): Boolean
@JvmStatic
external fun sendMessage(message: String)
@JvmStatic
private external fun ReleaseNetplayClient()
private fun mergeMessages(): Flow<NetplayMessage> = merge(
chatMessages.map { NetplayMessage.Chat(it) },
game.map { NetplayMessage.GameChanged(it) },
hostInputAuthorityEnabled.map { NetplayMessage.HostInputAuthorityChanged(it) },
padBuffer.map { NetplayMessage.BufferChanged(it) },
)
// NetPlayUI callbacks
@JvmStatic
@ -98,6 +176,26 @@ object Netplay {
_players.tryEmit(players.toList())
}
@JvmStatic
fun onChatMessageReceived(message: String) {
_chatMessages.tryEmit(message)
}
@JvmStatic
fun onHostInputAuthorityChanged(enabled: Boolean) {
_hostInputAuthorityEnabled.tryEmit(enabled)
}
@JvmStatic
fun onGameChanged(game: String) {
_game.tryEmit(game)
}
@JvmStatic
fun onPadBufferChanged(buffer: Int) {
_padBuffer.tryEmit(buffer)
}
// Settings
@JvmStatic

View File

@ -0,0 +1,29 @@
package org.dolphinemu.dolphinemu.features.netplay.model
import android.content.Context
import org.dolphinemu.dolphinemu.R
sealed class NetplayMessage {
abstract fun message(context: Context): String
class Chat(private val chatMessage: String) : NetplayMessage() {
override fun message(context: Context) = chatMessage
}
class GameChanged(private val game: String) : NetplayMessage() {
override fun message(context: Context) =
context.getString(R.string.netplay_message_game_changed, game)
}
class HostInputAuthorityChanged(private val hostInputAuthorityEnabled: Boolean) : NetplayMessage() {
override fun message(context: Context) = context.getString(
R.string.netplay_message_host_input_authority_changed,
if (hostInputAuthorityEnabled) "enabled" else "disabled"
)
}
class BufferChanged(private val buffer: Int) : NetplayMessage() {
override fun message(context: Context) =
context.getString(R.string.netplay_message_buffer_changed, buffer)
}
}

View File

@ -2,6 +2,8 @@
package org.dolphinemu.dolphinemu.features.netplay.model
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.DelicateCoroutinesApi
@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.dolphinemu.dolphinemu.features.netplay.Netplay
//TODO save settings
class NetplayViewModel : ViewModel() {
val launchGame = Netplay.launchGame
@ -23,12 +26,27 @@ class NetplayViewModel : ViewModel() {
val players = Netplay.players
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
val messages = Netplay.messages
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
val game = Netplay.game
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
init {
if (!Netplay.isClientConnected()) {
_goBack.trySend(Unit)
}
}
fun sendMessage(message: String) {
val trimmedMessage = message.trim()
if (trimmedMessage.isEmpty()) {
return
}
Netplay.sendMessage(trimmedMessage)
}
@OptIn(DelicateCoroutinesApi::class)
override fun onCleared() {
super.onCleared()

View File

@ -44,6 +44,9 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider {
DolphinTheme {
NetplayScreen(
onBackClicked = { finish() },
messages = viewModel.messages.collectAsState().value,
onSendMessage = viewModel::sendMessage,
game = viewModel.game.collectAsState().value,
players = viewModel.players.collectAsState().value,
)
}

View File

@ -16,7 +16,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@ -42,12 +45,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
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.NetplayMessage
import org.dolphinemu.dolphinemu.features.netplay.model.Player
import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme
import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer
@ -58,6 +64,9 @@ import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme
@Composable
fun NetplayScreen(
onBackClicked: () -> Unit,
messages: List<NetplayMessage>,
onSendMessage: (String) -> Unit,
game: String,
players: List<Player>,
) {
Scaffold(
@ -82,11 +91,17 @@ fun NetplayScreen(
if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) {
LandscapeContent(
messages = messages,
onSendMessage = onSendMessage,
game = game,
players = players,
modifier = modifier
)
} else {
PortraitContent(
messages = messages,
onSendMessage = onSendMessage,
game = game,
players = players,
modifier = modifier
)
@ -96,6 +111,9 @@ fun NetplayScreen(
@Composable
private fun PortraitContent(
messages: List<NetplayMessage>,
onSendMessage: (String) -> Unit,
game: String,
players: List<Player>,
modifier: Modifier = Modifier,
) {
@ -103,6 +121,8 @@ private fun PortraitContent(
modifier = modifier
) {
Chat(
messages = messages,
onSendMessage = onSendMessage,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.3f)
@ -112,6 +132,7 @@ private fun PortraitContent(
MenuSpacer()
PLayersAndSettings(
game = game,
players = players,
modifier = Modifier
.weight(1f)
@ -122,6 +143,9 @@ private fun PortraitContent(
@Composable
private fun LandscapeContent(
messages: List<NetplayMessage>,
onSendMessage: (String) -> Unit,
game: String,
players: List<Player>,
modifier: Modifier = Modifier,
) {
@ -129,6 +153,8 @@ private fun LandscapeContent(
modifier = modifier
) {
Chat(
messages = messages,
onSendMessage = onSendMessage,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
@ -136,6 +162,7 @@ private fun LandscapeContent(
)
PLayersAndSettings(
game = game,
players = players,
modifier = Modifier
.weight(1f)
@ -146,6 +173,7 @@ private fun LandscapeContent(
@Composable
private fun PLayersAndSettings(
game: String,
players: List<Player>,
modifier: Modifier = Modifier,
) {
@ -153,6 +181,17 @@ private fun PLayersAndSettings(
modifier = modifier
.verticalScroll(rememberScrollState())
) {
OutlinedTextField(
value = game,
onValueChange = {},
label = { Text(stringResource(R.string.netplay_game_label)) },
readOnly = true,
modifier = Modifier
.fillMaxWidth()
)
MenuSpacer()
PlayersTable(
rows = buildList {
add(listOf("Player", "Ping", "Mapping"))
@ -168,8 +207,24 @@ private fun PLayersAndSettings(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Chat(
messages: List<NetplayMessage>,
onSendMessage: (String) -> Unit,
modifier: Modifier,
) {
val context = LocalContext.current
fun LazyListScope.messages() {
items(messages.size) { index ->
Text(text = messages[index].message(context))
}
}
var draftMessage by remember { mutableStateOf("") }
val submitMessage = {
onSendMessage(draftMessage)
draftMessage = ""
}
var showBottomSheet by remember { mutableStateOf(false) }
val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@ -187,6 +242,7 @@ private fun Chat(
.weight(1f)
.padding(horizontal = DolphinTheme.scaffoldPadding)
) {
messages()
}
Row(
@ -197,19 +253,23 @@ private fun Chat(
.padding(horizontal = 8.dp)
) {
OutlinedTextField(
value = "",
onValueChange = {},
value = draftMessage,
onValueChange = { draftMessage = it },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { submitMessage() }),
modifier = Modifier
.weight(1f)
)
TextButton(
onClick = {},
onClick = submitMessage,
enabled = draftMessage.isNotBlank(),
) {
Text(stringResource(R.string.netplay_chat_send))
}
}
}
}
OutlinedBox(
onClick = { showBottomSheet = true },
label = { Text(stringResource(R.string.netplay_chat_label)) },
@ -221,6 +281,7 @@ private fun Chat(
modifier = Modifier
.fillMaxSize()
) {
messages()
}
}
}
@ -286,10 +347,7 @@ private fun PlayersTable(
@Composable
private fun NetplayScreenPreview() {
PreviewTheme(darkTheme = false) {
NetplayScreen(
onBackClicked = {},
players = previewPlayers,
)
PreviewNetplayScreen()
}
}
@ -297,10 +355,7 @@ private fun NetplayScreenPreview() {
@Composable
private fun NetplayScreenDarkPreview() {
PreviewTheme(darkTheme = true) {
NetplayScreen(
onBackClicked = {},
players = previewPlayers,
)
PreviewNetplayScreen()
}
}
@ -308,10 +363,7 @@ private fun NetplayScreenDarkPreview() {
@Composable
private fun LandscapeNetplayScreenPreview() {
PreviewTheme(darkTheme = false) {
NetplayScreen(
onBackClicked = {},
players = previewPlayers,
)
PreviewNetplayScreen()
}
}
@ -320,31 +372,42 @@ private fun LandscapeNetplayScreenPreview() {
heightDp = 411,
uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
private fun LandscapeNetplayScreenDarkPreview() {
PreviewTheme(darkTheme = true) {
NetplayScreen(
onBackClicked = {},
players = previewPlayers,
)
PreviewNetplayScreen()
}
}
private val previewPlayers = 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"
),
)
@Composable
private fun PreviewNetplayScreen() {
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"
),
),
messages = buildList {
repeat(5) {
add(NetplayMessage.Chat("Hello"))
}
},
onSendMessage = {},
game = "Game name",
)
}

View File

@ -999,4 +999,8 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="netplay_start">Start</string>
<string name="netplay_chat_label">Chat</string>
<string name="netplay_chat_send">Send</string>
<string name="netplay_message_game_changed">Game changed to %1$s</string>
<string name="netplay_message_buffer_changed">Buffer size changed to %1$d</string>
<string name="netplay_message_host_input_authority_changed">"Host input authority %1$s"</string>
<string name="netplay_game_label">Game</string>
</resources>

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_on_game_changed;
static jmethodID s_netplay_on_host_input_authority_changed;
static jmethodID s_netplay_on_pad_buffer_changed;
static jmethodID s_netplay_on_chat_message_received;
static jmethodID s_netplay_update;
static jclass s_netplay_player_class;
@ -268,6 +272,26 @@ jmethodID GetNetplayOnConnectionError()
return s_netplay_on_connection_error;
}
jmethodID GetNetplayOnGameChanged()
{
return s_netplay_on_game_changed;
}
jmethodID GetNetplayOnHostInputAuthorityChanged()
{
return s_netplay_on_host_input_authority_changed;
}
jmethodID GetNetplayOnPadBufferChanged()
{
return s_netplay_on_pad_buffer_changed;
}
jmethodID GetNetplayOnChatMessageReceived()
{
return s_netplay_on_chat_message_received;
}
jmethodID GetNetplayUpdate()
{
return s_netplay_update;
@ -694,6 +718,14 @@ 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_on_game_changed =
env->GetStaticMethodID(netplay_class, "onGameChanged", "(Ljava/lang/String;)V");
s_netplay_on_host_input_authority_changed =
env->GetStaticMethodID(netplay_class, "onHostInputAuthorityChanged", "(Z)V");
s_netplay_on_pad_buffer_changed =
env->GetStaticMethodID(netplay_class, "onPadBufferChanged", "(I)V");
s_netplay_on_chat_message_received =
env->GetStaticMethodID(netplay_class, "onChatMessageReceived", "(Ljava/lang/String;)V");
s_netplay_update = env->GetStaticMethodID(netplay_class, "onUpdate", "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V");
env->DeleteLocalRef(netplay_class);

View File

@ -36,6 +36,10 @@ jfieldID GetNetPlayClientPointer();
jfieldID GetNetplayBootSessionDataPointer();
jmethodID GetNetplayOnBootGame();
jmethodID GetNetplayOnConnectionError();
jmethodID GetNetplayOnGameChanged();
jmethodID GetNetplayOnHostInputAuthorityChanged();
jmethodID GetNetplayOnPadBufferChanged();
jmethodID GetNetplayOnChatMessageReceived();
jmethodID GetNetplayUpdate();
jclass GetNetplayPlayerClass();

View File

@ -59,13 +59,23 @@ void NetPlayUICallbacks::Update()
env->DeleteLocalRef(player_array);
}
void NetPlayUICallbacks::AppendChat(const std::string&) {}
void NetPlayUICallbacks::AppendChat(const std::string& message)
{
JNIEnv* env = IDCache::GetEnvForThread();
env->CallStaticVoidMethod(IDCache::GetNetplayClass(),
IDCache::GetNetplayOnChatMessageReceived(),
ToJString(env, message));
}
void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_identifier,
const std::string& netplay_name)
{
m_current_game_identifier = sync_identifier;
m_current_game_name = netplay_name;
JNIEnv* env = IDCache::GetEnvForThread();
env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnGameChanged(),
ToJString(env, netplay_name));
}
void NetPlayUICallbacks::OnMsgChangeGBARom(int, const NetPlay::GBAConfig&) {}
@ -86,8 +96,23 @@ void NetPlayUICallbacks::OnMsgStopGame() {}
void NetPlayUICallbacks::OnMsgPowerButton() {}
void NetPlayUICallbacks::OnPlayerConnect(const std::string&) {}
void NetPlayUICallbacks::OnPlayerDisconnect(const std::string&) {}
void NetPlayUICallbacks::OnPadBufferChanged(u32) {}
void NetPlayUICallbacks::OnHostInputAuthorityChanged(bool) {}
void NetPlayUICallbacks::OnPadBufferChanged(u32 buffer)
{
//TODO handle host input authority = true
JNIEnv* env = IDCache::GetEnvForThread();
env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnPadBufferChanged(),
static_cast<jint>(buffer));
}
void NetPlayUICallbacks::OnHostInputAuthorityChanged(bool enabled)
{
JNIEnv* env = IDCache::GetEnvForThread();
env->CallStaticVoidMethod(IDCache::GetNetplayClass(),
IDCache::GetNetplayOnHostInputAuthorityChanged(),
static_cast<jboolean>(enabled));
}
void NetPlayUICallbacks::OnDesync(u32, const std::string&) {}
void NetPlayUICallbacks::OnConnectionLost() {}

View File

@ -136,6 +136,14 @@ Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_isClientConnected(JNIEnv
return static_cast<jboolean>(GetPointer(env)->IsConnected());
}
JNIEXPORT void JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_sendMessage(JNIEnv* env, jclass,
jstring jmessage)
{
if (auto* client = GetPointer(env))
client->SendChatMessage(GetJString(env, jmessage));
}
JNIEXPORT jlong JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_Join(JNIEnv* env, jclass)
{