diff options
| author | Tom Pratt <tom.pratt@outlook.com> | 2026-04-14 09:57:01 +0200 |
|---|---|---|
| committer | Tom Pratt <tompratt@squareup.com> | 2026-05-19 12:02:55 +0200 |
| commit | c32732714ed8c782bfab7c621f41452469621c6f (patch) | |
| tree | c7f77b55399068470da206873ca9f8f1d9daf91d /Source/Android/app/src/main/java | |
| parent | dfa3384281876b2fdc1c4d08ae1f4996033fbcae (diff) | |
Implement more NetPlayUICallbacks
Includes chat, game changes, pad buffer changes and host input authority. Merges them all into the chat window.
Diffstat (limited to 'Source/Android/app/src/main/java')
5 files changed, 248 insertions, 37 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 ceda995051..88baa9f495 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 @@ -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 @@ -78,8 +146,18 @@ object Netplay { 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 diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt new file mode 100644 index 0000000000..41ab8997bd --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt @@ -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) + } +} 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 942dc0d0dc..cc9dc00ae0 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 @@ -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() 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 5a91ae5b91..c4d0ded675 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 @@ -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, ) } 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 c3e4742d5d..37cab3217c 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 @@ -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", + ) +} |
