summaryrefslogtreecommitdiff
path: root/Source/Android/app/src/main/java
diff options
context:
space:
mode:
authorTom Pratt <tom.pratt@outlook.com>2026-04-14 09:57:01 +0200
committerTom Pratt <tompratt@squareup.com>2026-05-19 12:02:55 +0200
commitc32732714ed8c782bfab7c621f41452469621c6f (patch)
treec7f77b55399068470da206873ca9f8f1d9daf91d /Source/Android/app/src/main/java
parentdfa3384281876b2fdc1c4d08ae1f4996033fbcae (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')
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt98
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt29
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt18
-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.kt137
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",
+ )
+}