summaryrefslogtreecommitdiff
path: root/Source/Android/app/src/main/java
diff options
context:
space:
mode:
authorJMC47 <JMC4789@gmail.com>2026-06-28 12:38:55 -0400
committerGitHub <noreply@github.com>2026-06-28 12:38:55 -0400
commite2ab15aa23fd05f5f90f71552ee33857bad5775e (patch)
tree5aa1d2177c73ffc9ce3b812ca991080c814ebc99 /Source/Android/app/src/main/java
parentbe33a68335e9ede4d8a54b9449ddda5329531d5a (diff)
parent1d1c9696aeee312e003d200fde105ab9da6a409c (diff)
Merge pull request #14647 from tom-pratt/netplay
Android netplay
Diffstat (limited to 'Source/Android/app/src/main/java')
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt6
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplayManager.kt37
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt448
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt26
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt29
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt16
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt18
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt36
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt168
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt224
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetworkMode.kt20
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt15
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt15
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt20
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt94
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt1372
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt87
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt415
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt3
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt16
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt1
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt18
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt31
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt6
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt337
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt2
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt5
27 files changed, 3460 insertions, 5 deletions
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt
index 2ead4b534b..b52b7b4fa4 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt
@@ -342,6 +342,12 @@ object NativeLibrary {
@JvmStatic
external fun RunSystemMenu()
+ /**
+ * Begins emulation for a netplay session, using the BootSessionData provided by the host.
+ */
+ @JvmStatic
+ external fun RunNetPlay(paths: Array<String>, riivolution: Boolean, bootSessionDataPointer: Long)
+
@JvmStatic
external fun ChangeDisc(path: String)
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplayManager.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplayManager.kt
new file mode 100644
index 0000000000..32742c7d93
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplayManager.kt
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+object NetplayManager {
+
+ private val mutex = Mutex()
+
+ @Volatile
+ private var closeComplete: CompletableDeferred<Unit>? = null
+
+ @Volatile
+ var activeSession: NetplaySession? = null
+ private set
+
+ suspend fun createSession(): NetplaySession = mutex.withLock {
+ closeComplete?.await()
+
+ // Sessions should be closed by UI navigation, but just in case.
+ activeSession?.closeBlocking()
+
+ closeComplete = CompletableDeferred()
+
+ NetplaySession(
+ onClosed = {
+ activeSession = null
+ closeComplete?.complete(Unit)
+ }
+ ).also {
+ activeSession = it
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt
new file mode 100644
index 0000000000..56676c4d3f
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt
@@ -0,0 +1,448 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay
+
+import androidx.annotation.Keep
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+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.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+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.GameDigestProgress
+import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage
+import org.dolphinemu.dolphinemu.features.netplay.model.Player
+import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress
+import org.dolphinemu.dolphinemu.features.netplay.model.TraversalState
+import org.dolphinemu.dolphinemu.features.settings.model.StringSetting
+import org.dolphinemu.dolphinemu.model.GameFile
+import org.dolphinemu.dolphinemu.services.GameFileCacheManager
+
+class NetplaySession(
+ private val onClosed: (NetplaySession) -> Unit,
+) {
+
+ @Keep
+ private var netPlayUICallbacksPointer: Long =
+ nativeCreateUICallbacks(GameFileCacheManager.getGameFiles().value ?: emptyArray())
+
+ @Keep
+ private var netPlayClientPointer: Long = 0
+
+ @Keep
+ private var netPlayServerPointer: Long = 0
+
+ @Keep
+ private var bootSessionDataPointer: Long = 0
+
+ private val sessionScope = CoroutineScope(SupervisorJob())
+
+ @Volatile
+ var isClosed = false
+ private set
+
+ val isHosting: Boolean
+ get() = netPlayServerPointer != 0L
+
+ val isLaunching: Boolean
+ get() = bootSessionDataPointer != 0L
+
+ val nickName by lazy { StringSetting.NETPLAY_NICKNAME.string }
+
+ private val _launchGame = Channel<String>(Channel.CONFLATED)
+ val launchGame = _launchGame.receiveAsFlow()
+
+ private val _stopGame = Channel<Unit>(Channel.CONFLATED)
+ val stopGame = _stopGame.receiveAsFlow()
+
+ private val _connectionLost = Channel<Unit>(Channel.CONFLATED)
+ val connectionLost = _connectionLost.receiveAsFlow()
+
+ 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>(
+ extraBufferCapacity = 32,
+ 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()
+
+ private val _desyncMessages = MutableSharedFlow<NetplayMessage.Desync>(
+ extraBufferCapacity = 32,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val desyncMessages = _desyncMessages.asSharedFlow()
+
+ private val _saveTransferProgress = MutableStateFlow<SaveTransferProgress?>(null)
+ val saveTransferProgress = _saveTransferProgress.asStateFlow()
+
+ private val _gameDigestProgress = MutableStateFlow<GameDigestProgress?>(null)
+ val gameDigestProgress = _gameDigestProgress.asStateFlow()
+
+ private val _traversalState = MutableSharedFlow<TraversalState>(
+ replay = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val traversalState = _traversalState.asSharedFlow()
+
+ private val _fatalTraversalError = Channel<TraversalState.Failure>(Channel.CONFLATED)
+ val fatalTraversalError = _fatalTraversalError.receiveAsFlow()
+
+ suspend fun join(): Boolean = withContext(Dispatchers.IO) {
+ if (isClosed) throw IllegalStateException("Cannot join a closed session")
+
+ mergeMessages()
+ .runningFold(emptyList<NetplayMessage>()) { acc, msg -> listOf(msg) + acc }
+ .onEach { _messages.tryEmit(it) }
+ .launchIn(sessionScope)
+
+ netPlayClientPointer = nativeJoin()
+
+ if (netPlayClientPointer == 0L || !isActive) {
+ closeBlocking()
+ return@withContext false
+ }
+
+ true
+ }
+
+ suspend fun host(): Boolean = withContext(Dispatchers.IO) {
+ if (isClosed) throw IllegalStateException("Cannot host a closed session")
+
+ netPlayServerPointer = nativeHost()
+ if (netPlayServerPointer == 0L || !isActive) {
+ closeBlocking()
+ return@withContext false
+ }
+
+ join()
+ }
+
+ fun sendMessage(message: String) {
+ _chatMessages.tryEmit("$nickName: $message")
+ nativeSendMessage(message)
+ }
+
+ fun setHostInputAuthority(enable: Boolean) = nativeSetHostInputAuthority(enable)
+
+ fun adjustClientPadBufferSize(buffer: Int) = nativeAdjustClientPadBufferSize(buffer)
+
+ fun adjustServerPadBufferSize(buffer: Int) = nativeAdjustServerPadBufferSize(buffer)
+
+ fun changeGame(gameFile: GameFile) = nativeChangeGame(gameFile)
+
+ fun doAllPlayersHaveGame(): Boolean = nativeDoAllPlayersHaveGame()
+
+ fun startGame() = nativeStartGame()
+
+ fun getPort(): Int = nativeGetPort()
+
+ fun getExternalIpAddress(): String? = nativeGetExternalIpAddress()
+
+ fun reconnectTraversal() = nativeReconnectTraversal()
+
+ fun consumeBootSessionData(): Long {
+ return bootSessionDataPointer.also {
+ bootSessionDataPointer = 0
+ }
+ }
+
+ suspend fun close() = withContext(Dispatchers.IO) {
+ closeBlocking()
+ }
+
+ @Synchronized
+ fun closeBlocking() {
+ if (isClosed) return
+ isClosed = true
+ sessionScope.cancel()
+ releaseNativeResources()
+ onClosed(this)
+ }
+
+ protected fun finalize() {
+ releaseNativeResources()
+ }
+
+ 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) },
+ desyncMessages,
+ )
+
+ private fun releaseNativeResources() {
+ val currentBootSessionDataPointer = bootSessionDataPointer
+ if (currentBootSessionDataPointer != 0L) {
+ bootSessionDataPointer = 0
+ nativeReleaseBootSessionData(currentBootSessionDataPointer)
+ }
+
+ val currentNetPlayClientPointer = netPlayClientPointer
+ if (currentNetPlayClientPointer != 0L) {
+ netPlayClientPointer = 0
+ nativeReleaseClient(currentNetPlayClientPointer)
+ }
+
+ val currentNetPlayServerPointer = netPlayServerPointer
+ if (currentNetPlayServerPointer != 0L) {
+ netPlayServerPointer = 0
+ nativeReleaseServer(currentNetPlayServerPointer)
+ }
+
+ val currentNetPlayUICallbacksPointer = netPlayUICallbacksPointer
+ if (currentNetPlayUICallbacksPointer != 0L) {
+ netPlayUICallbacksPointer = 0
+ nativeReleaseUICallbacks(currentNetPlayUICallbacksPointer)
+ }
+ }
+
+ // JNI methods
+
+ private external fun nativeCreateUICallbacks(gameFiles: Array<GameFile>): Long
+
+ private external fun nativeJoin(): Long
+
+ private external fun nativeHost(): Long
+
+ private external fun nativeSendMessage(message: String)
+
+ private external fun nativeSetHostInputAuthority(enable: Boolean)
+
+ private external fun nativeAdjustClientPadBufferSize(buffer: Int)
+
+ private external fun nativeAdjustServerPadBufferSize(buffer: Int)
+
+ private external fun nativeReleaseUICallbacks(pointer: Long)
+
+ private external fun nativeReleaseClient(pointer: Long)
+
+ private external fun nativeReleaseServer(pointer: Long)
+
+ private external fun nativeReleaseBootSessionData(pointer: Long)
+
+ private external fun nativeChangeGame(gameFile: GameFile)
+
+ private external fun nativeDoAllPlayersHaveGame(): Boolean
+
+ private external fun nativeStartGame()
+
+ private external fun nativeGetPort(): Int
+
+ private external fun nativeGetExternalIpAddress(): String?
+
+ private external fun nativeReconnectTraversal()
+
+ // NetPlayUI callbacks
+
+ @Keep
+ fun onBootGame(gameFilePath: String, bootSessionDataPointer: Long) {
+ this.bootSessionDataPointer = bootSessionDataPointer
+ _stopGame.flush()
+ _launchGame.trySend(gameFilePath)
+ }
+
+ @Keep
+ fun onStopGame() {
+ _stopGame.trySend(Unit)
+ }
+
+ @Keep
+ fun onConnectionLost() {
+ _connectionLost.trySend(Unit)
+ }
+
+ @Keep
+ fun onConnectionError(message: String) {
+ _connectionErrors.trySend(message)
+ }
+
+ @Keep
+ fun onUpdate(players: Array<Player>) {
+ _players.tryEmit(players.toList())
+ }
+
+ @Keep
+ fun onChatMessageReceived(message: String) {
+ _chatMessages.tryEmit(message)
+ }
+
+ @Keep
+ fun onHostInputAuthorityChanged(enabled: Boolean) {
+ _hostInputAuthorityEnabled.tryEmit(enabled)
+ }
+
+ @Keep
+ fun onGameChanged(game: String) {
+ _game.tryEmit(game)
+ }
+
+ @Keep
+ fun onPadBufferChanged(buffer: Int) {
+ if (_hostInputAuthorityEnabled.replayCache.firstOrNull() == true) return
+ _padBuffer.tryEmit(buffer)
+ }
+
+ @Keep
+ fun onDesync(frame: Int, player: String) {
+ _desyncMessages.tryEmit(NetplayMessage.Desync(player, frame))
+ }
+
+ @Keep
+ fun onShowChunkedProgressDialog(title: String, dataSize: Long, playerIds: IntArray) {
+ val players = _players.replayCache.firstOrNull()
+ _saveTransferProgress.value = SaveTransferProgress(
+ title = title,
+ totalSize = dataSize,
+ playerProgresses = playerIds.map { playerId ->
+ SaveTransferProgress.PlayerProgress(
+ playerId = playerId,
+ name = players?.find { it.pid == playerId }?.name ?: "Invalid Player ID",
+ progress = 0,
+ )
+ },
+ )
+ }
+
+ @Keep
+ fun onSetChunkedProgress(playerId: Int, progress: Long) {
+ val current = _saveTransferProgress.value
+ _saveTransferProgress.value = current?.copy(
+ playerProgresses = current.playerProgresses.map {
+ if (it.playerId == playerId) {
+ it.copy(progress = progress)
+ } else {
+ it
+ }
+ }
+ )
+ }
+
+ @Keep
+ fun onHideChunkedProgressDialog() {
+ _saveTransferProgress.value = null
+ }
+
+ @Keep
+ fun onShowGameDigestDialog(title: String) {
+ val players = _players.replayCache.firstOrNull()
+ _gameDigestProgress.value = GameDigestProgress(
+ title = title,
+ playerProgresses = players?.map { player ->
+ GameDigestProgress.PlayerProgress(
+ playerId = player.pid,
+ name = player.name,
+ progress = 0,
+ result = null,
+ )
+ } ?: emptyList(),
+ matches = null,
+ )
+ }
+
+ @Keep
+ fun onSetGameDigestProgress(playerId: Int, progress: Int) {
+ val current = _gameDigestProgress.value ?: return
+ _gameDigestProgress.value = current.copy(
+ playerProgresses = current.playerProgresses.map {
+ if (it.playerId == playerId) it.copy(progress = progress) else it
+ }
+ )
+ }
+
+ @Keep
+ fun onSetGameDigestResult(playerId: Int, result: String) {
+ val current = _gameDigestProgress.value ?: return
+ val updated = current.copy(
+ playerProgresses = current.playerProgresses.map {
+ if (it.playerId == playerId) it.copy(result = result) else it
+ }
+ )
+ val finished = updated.playerProgresses.all { it.result != null }
+ _gameDigestProgress.value = if (finished) {
+ val results = updated.playerProgresses.map { it.result }
+ updated.copy(matches = results.distinct().size == 1)
+ } else {
+ updated
+ }
+ }
+
+ /**
+ * Hosts send this when they dismiss their dialog even in a successful scenario. Ensuring
+ * that the value is cleared before a new game digest is started. Without this, StateFlow
+ * would not be a good choice.
+ */
+ @Keep
+ fun onAbortGameDigest() {
+ _gameDigestProgress.value = null
+ }
+
+ @Keep
+ fun onTraversalStateChanged(
+ state: Int,
+ hostCode: String?,
+ externalAddress: String?,
+ failureReason: String?,
+ ) {
+ val traversalState = when (state) {
+ 0 -> TraversalState.Connecting
+ 1 -> TraversalState.Connected(hostCode!!, externalAddress!!)
+ 2 -> TraversalState.Failure(failureReason!!)
+ else -> return
+ }
+ _traversalState.tryEmit(traversalState)
+
+ if (failureReason == "BadHost" || failureReason == "VersionTooOld") {
+ _fatalTraversalError.trySend(TraversalState.Failure(failureReason))
+ }
+ }
+}
+
+private fun <T> Channel<T>.flush() {
+ while (this.tryReceive().isSuccess) Unit
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt
new file mode 100644
index 0000000000..d1e0fd71d8
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+import androidx.annotation.StringRes
+import org.dolphinemu.dolphinemu.R
+
+sealed class ConnectionRole(
+ @StringRes val labelId: Int,
+ @StringRes val loadingLabelId: Int,
+) {
+ object Connect : ConnectionRole(
+ labelId = R.string.netplay_connection_role_connect,
+ loadingLabelId = R.string.netplay_connection_role_connect_loading,
+ )
+
+ object Host : ConnectionRole(
+ labelId = R.string.netplay_connection_role_host,
+ loadingLabelId = R.string.netplay_connection_role_host_loading,
+ )
+
+ companion object {
+ val all: List<ConnectionRole>
+ get() = listOf(Connect, Host)
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt
new file mode 100644
index 0000000000..f82018ea39
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+import androidx.annotation.StringRes
+import org.dolphinemu.dolphinemu.R
+
+sealed class ConnectionType(
+ @StringRes val labelId: Int,
+ val configValue: String,
+) {
+ object DirectConnection : ConnectionType(
+ labelId = R.string.netplay_connection_type_direct_connection,
+ configValue = "direct",
+ )
+
+ object TraversalServer : ConnectionType(
+ labelId = R.string.netplay_connection_type_traversal_server,
+ configValue = "traversal",
+ )
+
+ companion object {
+ val all: List<ConnectionType>
+ get() = listOf(DirectConnection, TraversalServer)
+
+ fun fromString(value: String): ConnectionType =
+ all.find { it.configValue == value } ?: throw IllegalArgumentException("Invalid connection type: $value")
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt
new file mode 100644
index 0000000000..923f03068a
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+data class GameDigestProgress(
+ val title: String,
+ val playerProgresses: List<PlayerProgress>,
+ val matches: Boolean?,
+) {
+ data class PlayerProgress(
+ val playerId: Int,
+ val name: String,
+ val progress: Int,
+ val result: String?,
+ )
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt
new file mode 100644
index 0000000000..7403f06f32
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+import androidx.annotation.StringRes
+import org.dolphinemu.dolphinemu.R
+
+enum class JoinInfoType(@StringRes val labelId: Int) {
+ ROOM_ID(R.string.netplay_address_type_room_id),
+ EXTERNAL(R.string.netplay_address_type_external),
+ LOCAL(R.string.netplay_address_type_local),
+}
+
+sealed class JoinAddress {
+ data object Loading : JoinAddress()
+ data class Loaded(val address: String) : JoinAddress()
+ data class Unknown(val retry: () -> Unit) : JoinAddress()
+}
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..c408d3be96
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt
@@ -0,0 +1,36 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+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)
+ }
+
+ class Desync(private val player: String, private val frame: Int) : NetplayMessage() {
+ override fun message(context: Context) =
+ context.getString(R.string.netplay_message_desync, player, frame)
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt
new file mode 100644
index 0000000000..9c667245e5
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt
@@ -0,0 +1,168 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.asFlow
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import org.dolphinemu.dolphinemu.features.netplay.NetplayManager
+import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting
+import org.dolphinemu.dolphinemu.features.settings.model.IntSetting
+import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig
+import org.dolphinemu.dolphinemu.features.settings.model.StringSetting
+import org.dolphinemu.dolphinemu.services.GameFileCacheManager
+
+class NetplaySetupViewModel(
+ private val netplayManager: NetplayManager,
+) : ViewModel() {
+
+ private val _connectionRole = MutableStateFlow<ConnectionRole>(ConnectionRole.Connect)
+ val connectionRole = _connectionRole.asStateFlow()
+
+ private val _nickname = MutableStateFlow(StringSetting.NETPLAY_NICKNAME.string)
+ val nickname = _nickname.asStateFlow()
+
+ private val _connectionType = MutableStateFlow(
+ ConnectionType.fromString(StringSetting.NETPLAY_TRAVERSAL_CHOICE.string)
+ )
+ val connectionType = _connectionType.asStateFlow()
+
+ private val _ipAddress = MutableStateFlow(StringSetting.NETPLAY_ADDRESS.string)
+ val ipAddress = _ipAddress.asStateFlow()
+
+ private val _hostCode = MutableStateFlow(StringSetting.NETPLAY_HOST_CODE.string)
+ val hostCode = _hostCode.asStateFlow()
+
+ private val _connectPort = MutableStateFlow(IntSetting.NETPLAY_CONNECT_PORT.int.toString())
+ val connectPort = _connectPort.asStateFlow()
+
+ private val _hostPort = MutableStateFlow(IntSetting.NETPLAY_HOST_PORT.int.toString())
+ val hostPort = _hostPort.asStateFlow()
+
+ private val _useUpnp = MutableStateFlow(BooleanSetting.NETPLAY_USE_UPNP.boolean)
+ val useUpnp = _useUpnp.asStateFlow()
+
+ private val _showNetplayScreen = Channel<Unit>(CONFLATED)
+ val showNetplayScreen = _showNetplayScreen.receiveAsFlow()
+
+ private val _connecting = MutableStateFlow(false)
+ val connecting = _connecting.asStateFlow()
+
+ private val _errors = MutableSharedFlow<String>(extraBufferCapacity = 8)
+ val errors = _errors.asSharedFlow()
+
+ init {
+ GameFileCacheManager.startLoad()
+ }
+
+ fun setConnectionRole(connectionRole: ConnectionRole) {
+ _connectionRole.value = connectionRole
+ }
+
+ fun setNickname(nickname: String) {
+ _nickname.value = nickname
+ StringSetting.NETPLAY_NICKNAME.setString(NativeConfig.LAYER_BASE, nickname)
+ }
+
+ fun setConnectionType(connectionType: ConnectionType) {
+ _connectionType.value = connectionType
+ StringSetting.NETPLAY_TRAVERSAL_CHOICE.setString(
+ NativeConfig.LAYER_BASE, connectionType.configValue
+ )
+ }
+
+ fun setIpAddress(ipAddress: String) {
+ if (ipAddress.all { it.isDigit() || it == '.' }) {
+ _ipAddress.value = ipAddress
+ StringSetting.NETPLAY_ADDRESS.setString(NativeConfig.LAYER_BASE, ipAddress)
+ }
+ }
+
+ fun setHostCode(hostCode: String) {
+ _hostCode.value = hostCode
+ StringSetting.NETPLAY_HOST_CODE.setString(NativeConfig.LAYER_BASE, hostCode)
+ }
+
+ fun setConnectPort(port: String) {
+ if (port.all { it.isDigit() }) {
+ _connectPort.value = port
+ port.toIntOrNull()?.let {
+ IntSetting.NETPLAY_CONNECT_PORT.setInt(NativeConfig.LAYER_BASE, it)
+ }
+ }
+ }
+
+ fun setHostPort(port: String) {
+ if (port.all { it.isDigit() }) {
+ _hostPort.value = port
+ port.toIntOrNull()?.let {
+ IntSetting.NETPLAY_HOST_PORT.setInt(NativeConfig.LAYER_BASE, it)
+ }
+ }
+ }
+
+ fun setUseUpnp(useUpnp: Boolean) {
+ _useUpnp.value = useUpnp
+ BooleanSetting.NETPLAY_USE_UPNP.setBoolean(NativeConfig.LAYER_BASE, useUpnp)
+ }
+
+ fun host() = connect(host = true)
+
+ fun connect(host: Boolean = false) {
+ if (_connecting.value) return
+
+ _connecting.value = true
+
+ viewModelScope.launch {
+ var errorForwarding: Job? = null
+
+ try {
+ GameFileCacheManager.isLoading().asFlow().first { it == false }
+
+ val session = netplayManager.createSession()
+ errorForwarding = session.connectionErrors
+ .onEach { _errors.emit(it) }
+ .launchIn(this)
+
+ val success = if (host) {
+ session.host()
+ } else {
+ session.join()
+ }
+ if (success) {
+ _showNetplayScreen.trySend(Unit)
+ }
+ } finally {
+ errorForwarding?.cancel()
+ _connecting.value = false
+ }
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ // There should not be an active session at this point but in case one was created
+ // but launching the Netplay screen failed, close it.
+ netplayManager.activeSession?.closeBlocking()
+ }
+
+ class Factory(private val netplayManager: NetplayManager) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun <T : ViewModel> create(modelClass: Class<T>): T {
+ return NetplaySetupViewModel(netplayManager) as T
+ }
+ }
+}
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
new file mode 100644
index 0000000000..adea1a13a5
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt
@@ -0,0 +1,224 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.asFlow
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.dolphinemu.dolphinemu.features.netplay.NetplaySession
+import org.dolphinemu.dolphinemu.features.settings.model.IntSetting
+import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig
+import org.dolphinemu.dolphinemu.features.settings.model.StringSetting
+import org.dolphinemu.dolphinemu.model.GameFile
+import org.dolphinemu.dolphinemu.services.GameFileCacheManager
+import org.dolphinemu.dolphinemu.utils.NetworkHelper
+
+class NetplayViewModel(
+ private val netplaySession: NetplaySession,
+ private val networkHelper: NetworkHelper,
+) : ViewModel() {
+
+ private val isTraversal = StringSetting.NETPLAY_TRAVERSAL_CHOICE.string == "traversal"
+
+ val launchGame = netplaySession.launchGame
+
+ val isHosting = netplaySession.isHosting
+
+ private val _joinAddresses = MutableStateFlow(
+ buildMap {
+ if (isHosting) {
+ if (isTraversal) {
+ put(JoinInfoType.ROOM_ID, JoinAddress.Loading)
+ }
+ put(JoinInfoType.EXTERNAL, JoinAddress.Loading)
+ put(JoinInfoType.LOCAL, getLocalIp())
+ }
+ }
+ )
+ val joinAddresses = _joinAddresses.asStateFlow()
+
+ val connectionLost = netplaySession.connectionLost
+
+ val fatalTraversalError = netplaySession.fatalTraversalError
+
+ val players = netplaySession.players
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
+
+ val messages = netplaySession.messages
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
+
+ val game = netplaySession.game
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
+
+ val hostInputAuthority = netplaySession.hostInputAuthorityEnabled
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
+
+ private val _networkMode = MutableStateFlow(
+ NetworkMode.fromConfigValue(StringSetting.NETPLAY_NETWORK_MODE.string)
+ )
+ val networkMode = _networkMode.asStateFlow()
+
+ private val _buffer = MutableStateFlow(IntSetting.NETPLAY_BUFFER_SIZE.int)
+ val buffer = _buffer.asStateFlow()
+
+ private val _clientBuffer = MutableStateFlow(IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.int)
+ val clientBuffer = _clientBuffer.asStateFlow()
+
+ val gameFiles = GameFileCacheManager.getGameFiles().asFlow()
+ .map { it.toList() }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ GameFileCacheManager.getGameFiles().value?.toList() ?: emptyList()
+ )
+
+ val saveTransferProgress = netplaySession.saveTransferProgress
+
+ val gameDigestProgress = netplaySession.gameDigestProgress
+
+ init {
+ if (netplaySession.isHosting) {
+ setInitialGame()
+ if (isTraversal) {
+ collectTraversalState()
+ } else {
+ fetchExternalIp()
+ }
+ }
+ }
+
+ private val _notAllPlayersHaveGame = Channel<Unit>(Channel.CONFLATED)
+ val notAllPlayersHaveGame = _notAllPlayersHaveGame.receiveAsFlow()
+
+ fun startGame() {
+ if (netplaySession.doAllPlayersHaveGame()) {
+ netplaySession.startGame()
+ } else {
+ _notAllPlayersHaveGame.trySend(Unit)
+ }
+ }
+
+ fun confirmStartGame() {
+ netplaySession.startGame()
+ }
+
+ fun sendMessage(message: String) {
+ val trimmedMessage = message.trim()
+ if (trimmedMessage.isEmpty()) {
+ return
+ }
+
+ netplaySession.sendMessage(trimmedMessage)
+ }
+
+ fun setNetworkMode(mode: NetworkMode) {
+ _networkMode.value = mode
+ StringSetting.NETPLAY_NETWORK_MODE.setString(NativeConfig.LAYER_BASE, mode.configValue)
+ netplaySession.setHostInputAuthority(mode.isHostInputAuthority)
+ }
+
+ fun setBuffer(value: Int) {
+ _buffer.value = value
+ IntSetting.NETPLAY_BUFFER_SIZE.setInt(NativeConfig.LAYER_BASE, value)
+ netplaySession.adjustServerPadBufferSize(value)
+ }
+
+ fun setClientBuffer(value: Int) {
+ _clientBuffer.value = value
+ IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.setInt(NativeConfig.LAYER_BASE, value)
+ netplaySession.adjustClientPadBufferSize(value)
+ }
+
+ fun changeGame(gameFile: GameFile) {
+ StringSetting.NETPLAY_GAME.setString(NativeConfig.LAYER_BASE, gameFile.getGameId())
+ netplaySession.changeGame(gameFile)
+ }
+
+ private fun getLocalIp(): JoinAddress {
+ val localIp = networkHelper.getLocalIpString()
+ ?: return JoinAddress.Unknown { _joinAddresses.value += JoinInfoType.LOCAL to getLocalIp() }
+ val port = netplaySession.getPort()
+ return JoinAddress.Loaded("$localIp:$port")
+ }
+
+ private fun fetchExternalIp() {
+ _joinAddresses.value += JoinInfoType.EXTERNAL to JoinAddress.Loading
+ viewModelScope.launch(Dispatchers.IO) {
+ val ip = netplaySession.getExternalIpAddress()
+ val port = netplaySession.getPort()
+ val address = if (ip != null) JoinAddress.Loaded("$ip:$port")
+ else JoinAddress.Unknown { fetchExternalIp() }
+ _joinAddresses.value += JoinInfoType.EXTERNAL to address
+ }
+ }
+
+ private fun collectTraversalState() {
+ val retry = { netplaySession.reconnectTraversal() }
+ netplaySession.traversalState.onEach { state ->
+ when (state) {
+ is TraversalState.Connecting -> {
+ _joinAddresses.value += mapOf(
+ JoinInfoType.ROOM_ID to JoinAddress.Loading,
+ JoinInfoType.EXTERNAL to JoinAddress.Loading,
+ )
+ }
+ is TraversalState.Connected -> {
+ _joinAddresses.value += mapOf(
+ JoinInfoType.ROOM_ID to JoinAddress.Loaded(state.hostCode),
+ JoinInfoType.EXTERNAL to JoinAddress.Loaded(state.externalAddress),
+ )
+ }
+ is TraversalState.Failure -> {
+ _joinAddresses.value += mapOf(
+ JoinInfoType.ROOM_ID to JoinAddress.Unknown(retry),
+ JoinInfoType.EXTERNAL to JoinAddress.Unknown(retry),
+ )
+ }
+ }
+ }.launchIn(viewModelScope)
+ }
+
+ private fun setInitialGame() {
+ val game = gameFiles.value
+ .find { it.getGameId() == StringSetting.NETPLAY_GAME.string }
+ ?: gameFiles.value.firstOrNull()
+
+ if (game != null) {
+ changeGame(game)
+ }
+ }
+
+ @OptIn(DelicateCoroutinesApi::class)
+ override fun onCleared() {
+ super.onCleared()
+ // Closing the netplay session is a bit slow for the main thread so launch in
+ // GlobalScope and allow the activity and view model to finish immediately.
+ GlobalScope.launch {
+ netplaySession.close()
+ }
+ }
+
+ class Factory(
+ private val session: NetplaySession,
+ private val networkHelper: NetworkHelper,
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun <T : ViewModel> create(modelClass: Class<T>): T {
+ return NetplayViewModel(session, networkHelper) as T
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetworkMode.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetworkMode.kt
new file mode 100644
index 0000000000..6c5f53f06b
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetworkMode.kt
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+import androidx.annotation.StringRes
+import org.dolphinemu.dolphinemu.R
+
+enum class NetworkMode(val configValue: String, @StringRes val labelId: Int) {
+ FAIR_INPUT_DELAY("fixeddelay", R.string.netplay_network_mode_fair_input_delay),
+ HOST_INPUT_AUTHORITY("hostinputauthority", R.string.netplay_network_mode_host_input_authority),
+ GOLF("golf", R.string.netplay_network_mode_golf);
+
+ val isHostInputAuthority: Boolean
+ get() = this == HOST_INPUT_AUTHORITY || this == GOLF
+
+ companion object {
+ fun fromConfigValue(value: String): NetworkMode =
+ entries.find { it.configValue == value } ?: FAIR_INPUT_DELAY
+ }
+}
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..31d0d0ccff
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+import androidx.annotation.Keep
+
+@Keep
+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/model/SaveTransferProgress.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt
new file mode 100644
index 0000000000..8265101415
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+data class SaveTransferProgress(
+ val title: String,
+ val totalSize: Long,
+ val playerProgresses: List<PlayerProgress>
+) {
+ data class PlayerProgress(
+ val playerId: Int,
+ val name: String,
+ val progress: Long,
+ )
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt
new file mode 100644
index 0000000000..0c36fdf58a
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+import android.content.Context
+import org.dolphinemu.dolphinemu.R
+
+sealed class TraversalState {
+ data object Connecting : TraversalState()
+
+ data class Connected(val hostCode: String, val externalAddress: String) : TraversalState()
+
+ data class Failure(val reason: String) : TraversalState() {
+ fun message(context: Context) = when (reason) {
+ "BadHost" -> context.getString(R.string.netplay_traversal_error_bad_host)
+ "VersionTooOld" -> context.getString(R.string.netplay_traversal_error_version_too_old)
+ else -> reason
+ }
+ }
+}
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
new file mode 100644
index 0000000000..eb9cecec20
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.ui
+
+import android.content.Context
+import android.content.Intent
+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
+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.NetplayManager
+import org.dolphinemu.dolphinemu.features.netplay.model.NetplayViewModel
+import org.dolphinemu.dolphinemu.ui.main.ThemeProvider
+import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme
+import org.dolphinemu.dolphinemu.utils.NetworkHelper
+import org.dolphinemu.dolphinemu.utils.ThemeHelper
+
+class NetplayActivity : AppCompatActivity(), ThemeProvider {
+ override var themeId: Int = 0
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ ThemeHelper.setTheme(this)
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+
+ val session = NetplayManager.activeSession
+ if (session == null) {
+ finish()
+ return
+ }
+
+ val viewModel = ViewModelProvider(this, NetplayViewModel.Factory(session, NetworkHelper))[NetplayViewModel::class.java]
+
+ viewModel.launchGame
+ .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
+ .onEach { EmulationActivity.launch(this, it, false) }
+ .launchIn(lifecycleScope)
+
+ setContent {
+ DolphinTheme {
+ NetplayScreen(
+ onBackClicked = { finish() },
+ isHosting = viewModel.isHosting,
+ connectionLost = viewModel.connectionLost,
+ fatalTraversalError = viewModel.fatalTraversalError,
+ messages = viewModel.messages.collectAsState().value,
+ onSendMessage = viewModel::sendMessage,
+ game = viewModel.game.collectAsState().value,
+ onStartGame = viewModel::startGame,
+ onGameSelected = viewModel::changeGame,
+ gameFiles = viewModel.gameFiles.collectAsState().value,
+ notAllPlayersHaveGame = viewModel.notAllPlayersHaveGame,
+ onConfirmStartGame = viewModel::confirmStartGame,
+ players = viewModel.players.collectAsState().value,
+ hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value,
+ networkMode = viewModel.networkMode.collectAsState().value,
+ onNetworkModeChanged = viewModel::setNetworkMode,
+ buffer = viewModel.buffer.collectAsState().value,
+ onBufferChanged = viewModel::setBuffer,
+ clientBuffer = viewModel.clientBuffer.collectAsState().value,
+ onClientBufferChanged = viewModel::setClientBuffer,
+ saveTransferProgress = viewModel.saveTransferProgress.collectAsState().value,
+ gameDigestProgress = viewModel.gameDigestProgress.collectAsState().value,
+ joinAddresses = viewModel.joinAddresses.collectAsState().value,
+ )
+ }
+ }
+ }
+
+ override fun setTheme(themeId: Int) {
+ super.setTheme(themeId)
+ this.themeId = themeId
+ }
+
+ override fun onResume() {
+ ThemeHelper.setCorrectTheme(this)
+ super.onResume()
+ }
+
+ companion object {
+ @JvmStatic
+ fun launch(context: Context) {
+ context.startActivity(Intent(context, NetplayActivity::class.java))
+ }
+ }
+}
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
new file mode 100644
index 0000000000..54ea403eeb
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt
@@ -0,0 +1,1372 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.ui
+
+import android.content.Intent
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+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.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+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
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Remove
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MediumTopAppBar
+import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+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.TextRange
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.window.core.layout.WindowSizeClass
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import org.dolphinemu.dolphinemu.R
+import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress
+import org.dolphinemu.dolphinemu.features.netplay.model.JoinAddress
+import org.dolphinemu.dolphinemu.features.netplay.model.JoinInfoType
+import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage
+import org.dolphinemu.dolphinemu.features.netplay.model.NetworkMode
+import org.dolphinemu.dolphinemu.features.netplay.model.Player
+import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress
+import org.dolphinemu.dolphinemu.features.netplay.model.TraversalState
+import org.dolphinemu.dolphinemu.model.GameFile
+import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme
+import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer
+import org.dolphinemu.dolphinemu.ui.theme.OutlinedBox
+import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme
+import org.dolphinemu.dolphinemu.ui.theme.ReadOnlyTextField
+import org.dolphinemu.dolphinemu.ui.theme.bottomFadeOverlay
+import org.dolphinemu.dolphinemu.ui.theme.rememberSheetState
+import org.dolphinemu.dolphinemu.utils.CoilUtils
+import java.util.Locale
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NetplayScreen(
+ onBackClicked: () -> Unit,
+ isHosting: Boolean,
+ connectionLost: Flow<Unit>,
+ fatalTraversalError: Flow<TraversalState.Failure>,
+ messages: List<NetplayMessage>,
+ onSendMessage: (String) -> Unit,
+ game: String,
+ onStartGame: () -> Unit,
+ onGameSelected: (GameFile) -> Unit,
+ gameFiles: List<GameFile>,
+ notAllPlayersHaveGame: Flow<Unit>,
+ onConfirmStartGame: () -> Unit,
+ hostInputAuthorityEnabled: Boolean,
+ networkMode: NetworkMode,
+ onNetworkModeChanged: (NetworkMode) -> Unit,
+ buffer: Int,
+ onBufferChanged: (Int) -> Unit,
+ clientBuffer: Int,
+ onClientBufferChanged: (Int) -> Unit,
+ players: List<Player>,
+ saveTransferProgress: SaveTransferProgress?,
+ gameDigestProgress: GameDigestProgress?,
+ joinAddresses: Map<JoinInfoType, JoinAddress>,
+) {
+ Scaffold(
+ topBar = {
+ MediumTopAppBar(
+ title = { Text(stringResource(R.string.netplay_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBackClicked) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ },
+ )
+ },
+ floatingActionButton = {
+ if (isHosting) {
+ ExtendedFloatingActionButton(onClick = onStartGame) {
+ Text(stringResource(R.string.netplay_start))
+ }
+ }
+ },
+ ) { innerPadding ->
+ val modifier = Modifier
+ .fillMaxSize()
+ .consumeWindowInsets(innerPadding)
+
+ // State which must live above the landscape/portrait split.
+ var showChat by rememberSaveable { mutableStateOf(false) }
+ var showGamePicker by rememberSaveable { mutableStateOf(false) }
+ var selectedJoinInfoType by rememberSaveable {
+ mutableStateOf(joinAddresses.keys.firstOrNull() ?: JoinInfoType.EXTERNAL)
+ }
+
+ val isLandscape =
+ LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
+ val isWidthAtLeastMedium = currentWindowAdaptiveInfo().windowSizeClass
+ .isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
+ val useWideLayout = isLandscape && isWidthAtLeastMedium
+
+ if (useWideLayout) {
+ LandscapeContent(
+ isHosting = isHosting,
+ messages = messages,
+ onSendMessage = onSendMessage,
+ showChat = showChat,
+ onShowChatChanged = { showChat = it },
+ game = game,
+ gameFiles = gameFiles,
+ onGameSelected = onGameSelected,
+ showGamePicker = showGamePicker,
+ onShowGamePickerChanged = { showGamePicker = it },
+ players = players,
+ hostInputAuthorityEnabled = hostInputAuthorityEnabled,
+ networkMode = networkMode,
+ onNetworkModeChanged = onNetworkModeChanged,
+ buffer = buffer,
+ onBufferChanged = onBufferChanged,
+ clientBuffer = clientBuffer,
+ onClientBufferChanged = onClientBufferChanged,
+ joinAddresses = joinAddresses,
+ selectedJoinInfoType = selectedJoinInfoType,
+ onSelectedJoinInfoTypeChanged = { selectedJoinInfoType = it },
+ contentPadding = innerPadding,
+ modifier = modifier
+ )
+ } else {
+ PortraitContent(
+ isHosting = isHosting,
+ messages = messages,
+ onSendMessage = onSendMessage,
+ showChat = showChat,
+ onShowChatChanged = { showChat = it },
+ game = game,
+ gameFiles = gameFiles,
+ onGameSelected = onGameSelected,
+ showGamePicker = showGamePicker,
+ onShowGamePickerChanged = { showGamePicker = it },
+ players = players,
+ hostInputAuthorityEnabled = hostInputAuthorityEnabled,
+ networkMode = networkMode,
+ onNetworkModeChanged = onNetworkModeChanged,
+ buffer = buffer,
+ onBufferChanged = onBufferChanged,
+ clientBuffer = clientBuffer,
+ onClientBufferChanged = onClientBufferChanged,
+ joinAddresses = joinAddresses,
+ selectedJoinInfoType = selectedJoinInfoType,
+ onSelectedJoinInfoTypeChanged = { selectedJoinInfoType = it },
+ contentPadding = innerPadding,
+ modifier = modifier
+ )
+ }
+
+ var showConnectionLostDialog by rememberSaveable { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ connectionLost.collect { showConnectionLostDialog = true }
+ }
+
+ var traversalError by rememberSaveable { mutableStateOf<TraversalState.Failure?>(null) }
+ LaunchedEffect(Unit) {
+ fatalTraversalError.collect { traversalError = it }
+ }
+
+ var showNotAllPlayersHaveGame by rememberSaveable { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ notAllPlayersHaveGame.collect { showNotAllPlayersHaveGame = true }
+ }
+
+ var dismissSaveTransferProgressDialog by rememberSaveable { mutableStateOf(false) }
+ if (saveTransferProgress == null) {
+ dismissSaveTransferProgressDialog = false
+ }
+
+ var dismissGameDigestDialog by rememberSaveable { mutableStateOf(false) }
+ if (gameDigestProgress == null) {
+ dismissGameDigestDialog = false
+ }
+
+ val currentTraversalError = traversalError
+
+ when {
+ showConnectionLostDialog -> {
+ AlertDialog(
+ text = { Text(stringResource(R.string.netplay_connection_lost)) },
+ confirmButton = {
+ TextButton(onClick = onBackClicked) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ onDismissRequest = onBackClicked,
+ )
+ }
+
+ currentTraversalError != null -> {
+ AlertDialog(
+ text = { Text(currentTraversalError.message(LocalContext.current)) },
+ confirmButton = {
+ TextButton(onClick = onBackClicked) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ onDismissRequest = onBackClicked,
+ )
+ }
+
+ saveTransferProgress != null && !dismissSaveTransferProgressDialog -> {
+ SaveTransferProgressDialog(
+ saveTransferProgress = saveTransferProgress,
+ onDismiss = { dismissSaveTransferProgressDialog = true },
+ )
+ }
+
+ gameDigestProgress != null && !dismissGameDigestDialog -> {
+ GameDigestProgressDialog(
+ gameDigestProgress = gameDigestProgress,
+ onDismiss = { dismissGameDigestDialog = true },
+ )
+ }
+
+ showNotAllPlayersHaveGame -> {
+ AlertDialog(
+ title = { Text(stringResource(R.string.netplay_start_warning_title)) },
+ text = { Text(stringResource(R.string.netplay_start_warning_not_all_players_have_game)) },
+ confirmButton = {
+ TextButton(onClick = {
+ showNotAllPlayersHaveGame = false
+ onConfirmStartGame()
+ }) {
+ Text(stringResource(R.string.yes))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showNotAllPlayersHaveGame = false }) {
+ Text(stringResource(R.string.no))
+ }
+ },
+ onDismissRequest = { showNotAllPlayersHaveGame = false },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PortraitContent(
+ isHosting: Boolean,
+ messages: List<NetplayMessage>,
+ onSendMessage: (String) -> Unit,
+ showChat: Boolean,
+ onShowChatChanged: (Boolean) -> Unit,
+ game: String,
+ gameFiles: List<GameFile>,
+ onGameSelected: (GameFile) -> Unit,
+ showGamePicker: Boolean,
+ onShowGamePickerChanged: (Boolean) -> Unit,
+ players: List<Player>,
+ hostInputAuthorityEnabled: Boolean,
+ networkMode: NetworkMode,
+ onNetworkModeChanged: (NetworkMode) -> Unit,
+ buffer: Int,
+ onBufferChanged: (Int) -> Unit,
+ clientBuffer: Int,
+ onClientBufferChanged: (Int) -> Unit,
+ joinAddresses: Map<JoinInfoType, JoinAddress>,
+ selectedJoinInfoType: JoinInfoType,
+ onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit,
+ contentPadding: PaddingValues,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .verticalScroll(rememberScrollState())
+ .padding(contentPadding)
+ ) {
+ Chat(
+ messages = messages,
+ onSendMessage = onSendMessage,
+ showBottomSheet = showChat,
+ onShowBottomSheetChanged = onShowChatChanged,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .padding(horizontal = DolphinTheme.scaffoldPadding)
+ )
+
+ MenuSpacer()
+
+ PlayersAndSettings(
+ game = game,
+ gameFiles = gameFiles,
+ onGameSelected = onGameSelected,
+ showGamePicker = showGamePicker,
+ onShowGamePickerChanged = onShowGamePickerChanged,
+ players = players,
+ hostInputAuthorityEnabled = hostInputAuthorityEnabled,
+ networkMode = networkMode,
+ onNetworkModeChanged = onNetworkModeChanged,
+ buffer = buffer,
+ onBufferChanged = onBufferChanged,
+ clientBuffer = clientBuffer,
+ onClientBufferChanged = onClientBufferChanged,
+ isHosting = isHosting,
+ joinAddresses = joinAddresses,
+ selectedJoinInfoType = selectedJoinInfoType,
+ onSelectedJoinInfoTypeChanged = onSelectedJoinInfoTypeChanged,
+ modifier = Modifier
+ .padding(horizontal = DolphinTheme.scaffoldPadding),
+ )
+
+ if (isHosting) {
+ Spacer(modifier = Modifier.height(DolphinTheme.fabClearancePadding))
+ }
+ }
+}
+
+@Composable
+private fun LandscapeContent(
+ isHosting: Boolean,
+ messages: List<NetplayMessage>,
+ onSendMessage: (String) -> Unit,
+ showChat: Boolean,
+ onShowChatChanged: (Boolean) -> Unit,
+ game: String,
+ gameFiles: List<GameFile>,
+ onGameSelected: (GameFile) -> Unit,
+ showGamePicker: Boolean,
+ onShowGamePickerChanged: (Boolean) -> Unit,
+ players: List<Player>,
+ hostInputAuthorityEnabled: Boolean,
+ networkMode: NetworkMode,
+ onNetworkModeChanged: (NetworkMode) -> Unit,
+ buffer: Int,
+ onBufferChanged: (Int) -> Unit,
+ clientBuffer: Int,
+ onClientBufferChanged: (Int) -> Unit,
+ joinAddresses: Map<JoinInfoType, JoinAddress>,
+ selectedJoinInfoType: JoinInfoType,
+ onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit,
+ contentPadding: PaddingValues,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ ) {
+ Chat(
+ messages = messages,
+ onSendMessage = onSendMessage,
+ showBottomSheet = showChat,
+ onShowBottomSheetChanged = onShowChatChanged,
+ modifier = Modifier
+ .padding(contentPadding)
+ .padding(
+ start = DolphinTheme.scaffoldPadding,
+ end = DolphinTheme.scaffoldPadding / 2,
+ )
+ .weight(1f)
+ .fillMaxHeight()
+ )
+
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .bottomFadeOverlay(scrollState, contentPadding.calculateBottomPadding())
+ .verticalScroll(scrollState)
+ .padding(contentPadding)
+ .padding(
+ start = DolphinTheme.scaffoldPadding / 2,
+ end = DolphinTheme.scaffoldPadding
+ )
+ ) {
+ PlayersAndSettings(
+ game = game,
+ gameFiles = gameFiles,
+ onGameSelected = onGameSelected,
+ showGamePicker = showGamePicker,
+ onShowGamePickerChanged = onShowGamePickerChanged,
+ players = players,
+ hostInputAuthorityEnabled = hostInputAuthorityEnabled,
+ networkMode = networkMode,
+ onNetworkModeChanged = onNetworkModeChanged,
+ buffer = buffer,
+ onBufferChanged = onBufferChanged,
+ clientBuffer = clientBuffer,
+ onClientBufferChanged = onClientBufferChanged,
+ isHosting = isHosting,
+ joinAddresses = joinAddresses,
+ selectedJoinInfoType = selectedJoinInfoType,
+ onSelectedJoinInfoTypeChanged = onSelectedJoinInfoTypeChanged,
+ modifier = Modifier
+ )
+
+ if (isHosting) {
+ Spacer(modifier = Modifier.height(DolphinTheme.fabClearancePadding))
+ }
+ }
+ }
+}
+
+@Composable
+private fun PlayersAndSettings(
+ game: String,
+ gameFiles: List<GameFile>,
+ onGameSelected: (GameFile) -> Unit,
+ showGamePicker: Boolean,
+ onShowGamePickerChanged: (Boolean) -> Unit,
+ players: List<Player>,
+ hostInputAuthorityEnabled: Boolean,
+ networkMode: NetworkMode,
+ onNetworkModeChanged: (NetworkMode) -> Unit,
+ buffer: Int,
+ onBufferChanged: (Int) -> Unit,
+ clientBuffer: Int,
+ onClientBufferChanged: (Int) -> Unit,
+ isHosting: Boolean,
+ joinAddresses: Map<JoinInfoType, JoinAddress>,
+ selectedJoinInfoType: JoinInfoType,
+ onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ ) {
+ GamePicker(
+ game = game,
+ gameFiles = gameFiles,
+ onGameSelected = onGameSelected,
+ showGamePicker = showGamePicker,
+ onShowGamePickerChanged = onShowGamePickerChanged,
+ isHosting = isHosting,
+ )
+
+ if (isHosting) {
+ MenuSpacer()
+
+ JoinAddressSection(
+ joinAddresses = joinAddresses,
+ selectedType = selectedJoinInfoType,
+ onSelectedTypeChanged = onSelectedJoinInfoTypeChanged,
+ )
+ }
+
+ MenuSpacer()
+
+ OutlinedBox(
+ label = { Text(stringResource(R.string.netplay_players_label)) },
+ ) {
+ PlayersTable(
+ rows = buildList {
+ add(
+ listOf(
+ stringResource(R.string.netplay_players_name),
+ stringResource(R.string.netplay_players_ping),
+ stringResource(R.string.netplay_players_mapping),
+ )
+ )
+ addAll(players.map { listOf(it.name, it.ping.toString(), it.mapping) })
+ repeat(4 - players.size) { add(listOf("", "", "")) }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ )
+ }
+
+ if (isHosting) {
+ MenuSpacer()
+
+ NetworkModeDropdown(
+ networkMode = networkMode,
+ onNetworkModeChanged = onNetworkModeChanged,
+ )
+ }
+
+ if (isHosting && !hostInputAuthorityEnabled) {
+ MenuSpacer()
+
+ BufferInput(
+ value = buffer,
+ onValueChange = onBufferChanged,
+ label = stringResource(R.string.netplay_buffer),
+ )
+ }
+
+ if (!isHosting && hostInputAuthorityEnabled) {
+ MenuSpacer()
+
+ BufferInput(
+ value = clientBuffer,
+ onValueChange = onClientBufferChanged,
+ label = stringResource(R.string.netplay_client_buffer),
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Chat(
+ messages: List<NetplayMessage>,
+ onSendMessage: (String) -> Unit,
+ showBottomSheet: Boolean,
+ onShowBottomSheetChanged: (Boolean) -> Unit,
+ modifier: Modifier,
+) {
+ val context = LocalContext.current
+
+ fun LazyListScope.messages() {
+ items(messages.size) { index ->
+ Text(
+ text = messages[index].message(context),
+ style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 18.sp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 2.dp)
+ )
+ }
+ }
+
+ var draftMessage by remember { mutableStateOf("") }
+ val submitMessage = {
+ onSendMessage(draftMessage)
+ draftMessage = ""
+ }
+
+ val bottomSheetState = rememberSheetState(
+ skipPartiallyExpanded = true,
+ initialValue = if (showBottomSheet) SheetValue.Expanded else SheetValue.Hidden,
+ )
+
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { onShowBottomSheetChanged(false) },
+ sheetState = bottomSheetState,
+ modifier = Modifier
+ .statusBarsPadding()
+ ) {
+ LazyColumn(
+ reverseLayout = true,
+ contentPadding = PaddingValues(bottom = 4.dp),
+ modifier = Modifier
+ .weight(1f, fill = false)
+ .padding(horizontal = DolphinTheme.scaffoldPadding)
+ ) {
+ messages()
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ ) {
+ OutlinedTextField(
+ value = draftMessage,
+ onValueChange = { draftMessage = it },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
+ keyboardActions = KeyboardActions(onSend = { submitMessage() }),
+ modifier = Modifier
+ .weight(1f)
+ )
+ IconButton(
+ onClick = submitMessage,
+ enabled = draftMessage.isNotBlank(),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Send,
+ contentDescription = stringResource(R.string.netplay_chat_send),
+ )
+ }
+ }
+ }
+ }
+
+ OutlinedBox(
+ onClick = { onShowBottomSheetChanged(true) },
+ label = { Text(stringResource(R.string.netplay_chat_label)) },
+ fadeContentTop = true,
+ modifier = modifier
+ ) {
+ LazyColumn(
+ reverseLayout = true,
+ userScrollEnabled = false,
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ messages()
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun GamePicker(
+ game: String,
+ gameFiles: List<GameFile>,
+ onGameSelected: (GameFile) -> Unit,
+ showGamePicker: Boolean,
+ onShowGamePickerChanged: (Boolean) -> Unit,
+ isHosting: Boolean,
+) {
+ val bottomSheetState = rememberSheetState(
+ skipPartiallyExpanded = true,
+ initialValue = if (showGamePicker) SheetValue.Expanded else SheetValue.Hidden,
+ )
+
+ if (showGamePicker) {
+ ModalBottomSheet(
+ onDismissRequest = { onShowGamePickerChanged(false) },
+ sheetState = bottomSheetState,
+ modifier = Modifier.statusBarsPadding()
+ ) {
+ GameList(
+ gameFiles = gameFiles,
+ onGameSelected = { gameFile ->
+ onGameSelected(gameFile)
+ onShowGamePickerChanged(false)
+ },
+ contentPadding = PaddingValues(
+ start = DolphinTheme.scaffoldPadding,
+ end = DolphinTheme.scaffoldPadding,
+ bottom = 16.dp
+ ),
+ )
+ }
+ }
+
+ ReadOnlyTextField(
+ value = game,
+ label = stringResource(R.string.netplay_game_label),
+ onClick = if (isHosting) {
+ { onShowGamePickerChanged(true) }
+ } else {
+ null
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+}
+
+@Composable
+private fun GameList(
+ gameFiles: List<GameFile>,
+ onGameSelected: (GameFile) -> Unit,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 120.dp),
+ contentPadding = contentPadding,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(gameFiles, key = { it.getPath() }) { gameFile ->
+ GameGridItem(
+ gameFile = gameFile,
+ onClick = { onGameSelected(gameFile) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun GameGridItem(
+ gameFile: GameFile,
+ onClick: () -> Unit,
+) {
+ Card(
+ onClick = onClick,
+ ) {
+ Column {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(gameFile)
+ .error(R.drawable.no_banner)
+ .build(),
+ contentDescription = gameFile.getTitle(),
+ contentScale = ContentScale.Crop,
+ imageLoader = CoilUtils.imageLoader,
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(0.7f)
+ )
+ Text(
+ text = gameFile.getTitle(),
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 2,
+ minLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .padding(8.dp)
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun JoinAddressSection(
+ joinAddresses: Map<JoinInfoType, JoinAddress>,
+ selectedType: JoinInfoType,
+ onSelectedTypeChanged: (JoinInfoType) -> Unit,
+) {
+ val address = joinAddresses[selectedType] ?: joinAddresses.values.first()
+
+ @Suppress("UnusedBoxWithConstraintsScope")
+ BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
+ if (maxWidth > 392.dp) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ JoinInfoDropdown(
+ joinAddresses = joinAddresses,
+ selectedType = selectedType,
+ onSelectedTypeChanged = onSelectedTypeChanged,
+ modifier = Modifier.weight(0.39f),
+ )
+ AddressRow(
+ joinInfoType = selectedType,
+ address = address,
+ modifier = Modifier.weight(0.61f),
+ )
+ }
+ } else {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ JoinInfoDropdown(
+ joinAddresses = joinAddresses,
+ selectedType = selectedType,
+ onSelectedTypeChanged = onSelectedTypeChanged,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ MenuSpacer()
+ AddressRow(
+ joinInfoType = selectedType,
+ address = address,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun JoinInfoDropdown(
+ joinAddresses: Map<JoinInfoType, JoinAddress>,
+ selectedType: JoinInfoType,
+ onSelectedTypeChanged: (JoinInfoType) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ modifier = modifier,
+ ) {
+ OutlinedTextField(
+ value = stringResource(selectedType.labelId),
+ onValueChange = {},
+ readOnly = true,
+ label = { Text(stringResource(R.string.netplay_host_address_label)) },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
+ modifier = Modifier
+ .menuAnchor(MenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth()
+ )
+
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ joinAddresses.keys.forEach { type ->
+ DropdownMenuItem(
+ text = { Text(stringResource(type.labelId)) },
+ onClick = {
+ onSelectedTypeChanged(type)
+ expanded = false
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AddressRow(
+ joinInfoType: JoinInfoType,
+ address: JoinAddress,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+
+ ReadOnlyTextField(
+ value = when (address) {
+ is JoinAddress.Loading -> stringResource(R.string.netplay_address_loading)
+ is JoinAddress.Loaded -> address.address
+ is JoinAddress.Unknown -> stringResource(R.string.netplay_address_unknown)
+ },
+ label = stringResource(
+ if (joinInfoType == JoinInfoType.ROOM_ID) R.string.netplay_code_label
+ else R.string.netplay_address_label
+ ),
+ onClick = when (address) {
+ is JoinAddress.Loaded -> {
+ {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, address.address)
+ }
+ context.startActivity(Intent.createChooser(intent, null))
+ }
+ }
+
+ is JoinAddress.Unknown -> address.retry
+ is JoinAddress.Loading -> null
+ },
+ textStyle = if (address is JoinAddress.Loading) {
+ LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
+ } else {
+ null
+ },
+ trailingIcon = {
+ when (address) {
+ is JoinAddress.Loaded -> Icon(
+ imageVector = Icons.Filled.Share,
+ contentDescription = stringResource(R.string.netplay_address_share),
+ )
+
+ is JoinAddress.Unknown -> Icon(
+ imageVector = Icons.Filled.Refresh,
+ contentDescription = stringResource(R.string.netplay_address_retry),
+ )
+
+ is JoinAddress.Loading -> CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp,
+ )
+ }
+ },
+ modifier = modifier,
+ )
+}
+
+/**
+ * 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()
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun NetworkModeDropdown(
+ networkMode: NetworkMode,
+ onNetworkModeChanged: (NetworkMode) -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ ) {
+ OutlinedTextField(
+ value = stringResource(networkMode.labelId),
+ onValueChange = {},
+ readOnly = true,
+ label = { Text(stringResource(R.string.netplay_network_mode_label)) },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
+ modifier = Modifier
+ .menuAnchor(MenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ )
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ // No golf mode for now since it requires in game UI.
+ listOf(NetworkMode.FAIR_INPUT_DELAY, NetworkMode.HOST_INPUT_AUTHORITY).forEach { mode ->
+ DropdownMenuItem(
+ text = { Text(stringResource(mode.labelId)) },
+ onClick = {
+ onNetworkModeChanged(mode)
+ expanded = false
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun BufferInput(
+ value: Int,
+ onValueChange: (Int) -> Unit,
+ label: String,
+) {
+ val range = 0..99
+ var maybeEmptyValue by remember(value) {
+ mutableStateOf("$value")
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ OutlinedTextField(
+ value = TextFieldValue(
+ text = maybeEmptyValue,
+ selection = TextRange(maybeEmptyValue.length)
+ ),
+ onValueChange = { newValue ->
+ if (newValue.text.isEmpty()) {
+ maybeEmptyValue = newValue.text
+ return@OutlinedTextField
+ }
+ newValue.text.toIntOrNull()?.let {
+ if (it in range) {
+ onValueChange(it)
+ }
+ }
+ },
+ label = { Text(label) },
+ textStyle = LocalTextStyle.current.copy(
+ textAlign = TextAlign.Center
+ ),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ ),
+ singleLine = true,
+ modifier = Modifier
+ .weight(1f)
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Button(
+ onClick = {
+ if (maybeEmptyValue.isEmpty()) {
+ maybeEmptyValue = "0"
+ onValueChange(0)
+ } else {
+ val newValue = value - 1
+ if (newValue in range) {
+ onValueChange(newValue)
+ }
+ }
+ },
+ shape = RoundedCornerShape(
+ topStartPercent = 50,
+ topEndPercent = 0,
+ bottomEndPercent = 0,
+ bottomStartPercent = 50,
+ ),
+ modifier = Modifier
+ .height(60.dp)
+ .padding(top = 8.dp)
+ ) {
+ Icon(Icons.Filled.Remove, contentDescription = "Back")
+ }
+
+ Spacer(modifier = Modifier.width(2.dp))
+
+ Button(
+ onClick = {
+ if (maybeEmptyValue.isEmpty()) {
+ maybeEmptyValue = "0"
+ onValueChange(0)
+ } else {
+ val newValue = value + 1
+ if (newValue in range) {
+ onValueChange(newValue)
+ }
+ }
+ },
+ shape = RoundedCornerShape(
+ topStartPercent = 0,
+ topEndPercent = 50,
+ bottomEndPercent = 50,
+ bottomStartPercent = 0,
+ ),
+ modifier = Modifier
+ .height(60.dp)
+ .padding(top = 8.dp)
+ ) {
+ Icon(Icons.Filled.Add, contentDescription = "Back")
+ }
+ }
+}
+
+@Composable
+private fun SaveTransferProgressDialog(
+ saveTransferProgress: SaveTransferProgress,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ title = { Text(saveTransferProgress.title) },
+ text = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ ) {
+ saveTransferProgress.playerProgresses.forEachIndexed { index, playerProgress ->
+ SaveTransferProgressRow(
+ playerProgress = playerProgress,
+ totalSize = saveTransferProgress.totalSize,
+ )
+
+ if (index < saveTransferProgress.playerProgresses.lastIndex) {
+ HorizontalDivider()
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.netplay_save_transfer_progress_close))
+ }
+ },
+ onDismissRequest = onDismiss,
+ )
+}
+
+@Composable
+private fun SaveTransferProgressRow(
+ playerProgress: SaveTransferProgress.PlayerProgress,
+ totalSize: Long,
+) {
+ fun formatMib(bytes: Long) = String.format(Locale.US, "%.2f", bytes / 1024f / 1024f)
+ val progressFraction = (playerProgress.progress.toFloat() / totalSize).coerceIn(0f, 1f)
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ LinearProgressIndicator(
+ progress = { progressFraction },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = playerProgress.name,
+ modifier = Modifier.weight(1f),
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = stringResource(
+ R.string.netplay_transfer_progress,
+ formatMib(playerProgress.progress),
+ formatMib(totalSize)
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+private fun GameDigestProgressDialog(
+ gameDigestProgress: GameDigestProgress,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ title = { Text(gameDigestProgress.title) },
+ text = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ ) {
+ gameDigestProgress.playerProgresses.forEachIndexed { index, playerProgress ->
+ GameDigestPlayerRow(playerProgress)
+ if (index < gameDigestProgress.playerProgresses.lastIndex) {
+ HorizontalDivider()
+ }
+ }
+ if (gameDigestProgress.matches != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(
+ if (gameDigestProgress.matches) {
+ R.string.netplay_game_digest_match
+ } else {
+ R.string.netplay_game_digest_mismatch
+ }
+ ),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ },
+ confirmButton = {
+ if (gameDigestProgress.matches != null) {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.netplay_game_digest_close))
+ }
+ }
+ },
+ onDismissRequest = { onDismiss() },
+ )
+}
+
+@Composable
+private fun GameDigestPlayerRow(
+ playerProgress: GameDigestProgress.PlayerProgress,
+) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ LinearProgressIndicator(
+ progress = { playerProgress.progress / 100f },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ if (playerProgress.result == null) {
+ Text(
+ text = playerProgress.name,
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = "${playerProgress.progress}%",
+ )
+ } else {
+ Text(
+ text = "${playerProgress.name}:\u00A0${playerProgress.result}",
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun NetplayScreenPreview() {
+ PreviewTheme(darkTheme = false) {
+ PreviewNetplayScreen()
+ }
+}
+
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun NetplayScreenDarkPreview() {
+ PreviewTheme(darkTheme = true) {
+ PreviewNetplayScreen()
+ }
+}
+
+@Preview(widthDp = 891, heightDp = 411)
+@Composable
+private fun LandscapeNetplayScreenPreview() {
+ PreviewTheme(darkTheme = false) {
+ PreviewNetplayScreen()
+ }
+}
+
+@Preview(
+ widthDp = 891,
+ heightDp = 411,
+ uiMode = Configuration.UI_MODE_NIGHT_YES
+)
+
+@Composable
+private fun LandscapeNetplayScreenDarkPreview() {
+ PreviewTheme(darkTheme = true) {
+ PreviewNetplayScreen()
+ }
+}
+
+@Composable
+private fun PreviewNetplayScreen() {
+ NetplayScreen(
+ onBackClicked = {},
+ connectionLost = emptyFlow(),
+ fatalTraversalError = emptyFlow(),
+ 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",
+ isHosting = true,
+ onStartGame = {},
+ onGameSelected = {},
+ gameFiles = emptyList(),
+ notAllPlayersHaveGame = emptyFlow(),
+ onConfirmStartGame = {},
+ hostInputAuthorityEnabled = true,
+ networkMode = NetworkMode.HOST_INPUT_AUTHORITY,
+ onNetworkModeChanged = {},
+ buffer = 5,
+ onBufferChanged = {},
+ clientBuffer = 10,
+ onClientBufferChanged = {},
+ saveTransferProgress = null,
+ gameDigestProgress = null,
+ joinAddresses = mapOf(
+ JoinInfoType.EXTERNAL to JoinAddress.Loaded("203.0.113.1:2626"),
+ JoinInfoType.LOCAL to JoinAddress.Loaded("192.168.1.5:2626"),
+ ),
+// saveTransferProgress = SaveTransferProgress(
+// title = "Title",
+// totalSize = 1024L,
+// playerProgresses = listOf(
+// SaveTransferProgress.PlayerProgress(
+// playerId = 1,
+// name = "Player 1",
+// progress = 256,
+// ),
+// SaveTransferProgress.PlayerProgress(
+// playerId = 2,
+// name = "Player 2",
+// progress = 512,
+// ),
+// ),
+// ),
+ )
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt
new file mode 100644
index 0000000000..a82dbff15a
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.ui
+
+import android.content.Context
+import android.content.Intent
+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
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.dolphinemu.dolphinemu.features.netplay.NetplayManager
+import org.dolphinemu.dolphinemu.features.netplay.model.NetplaySetupViewModel
+import org.dolphinemu.dolphinemu.ui.main.ThemeProvider
+import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme
+import org.dolphinemu.dolphinemu.utils.ThemeHelper
+
+class NetplaySetupActivity : AppCompatActivity(), ThemeProvider {
+ override var themeId: Int = 0
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ ThemeHelper.setTheme(this)
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+
+ val viewModel = ViewModelProvider(
+ this,
+ NetplaySetupViewModel.Factory(NetplayManager)
+ )[NetplaySetupViewModel::class.java]
+
+ viewModel.showNetplayScreen
+ .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
+ .onEach { NetplayActivity.launch(this) }
+ .launchIn(lifecycleScope)
+
+ setContent {
+ DolphinTheme {
+ NetplaySetupScreen(
+ onBackClicked = { finish() },
+ connecting = viewModel.connecting.collectAsState().value,
+ errors = viewModel.errors,
+ nickname = viewModel.nickname.collectAsState().value,
+ onNicknameChanged = viewModel::setNickname,
+ connectionType = viewModel.connectionType.collectAsState().value,
+ onConnectionTypeChanged = viewModel::setConnectionType,
+ connectionRole = viewModel.connectionRole.collectAsState().value,
+ onConnectionRoleChanged = viewModel::setConnectionRole,
+ ipAddress = viewModel.ipAddress.collectAsState().value,
+ onIpAddressChanged = viewModel::setIpAddress,
+ connectPort = viewModel.connectPort.collectAsState().value,
+ onConnectPortChanged = viewModel::setConnectPort,
+ hostCode = viewModel.hostCode.collectAsState().value,
+ onHostCodeChanged = viewModel::setHostCode,
+ hostPort = viewModel.hostPort.collectAsState().value,
+ onHostPortChanged = viewModel::setHostPort,
+ useUpnp = viewModel.useUpnp.collectAsState().value,
+ onUseUpnpChanged = viewModel::setUseUpnp,
+ onHostClicked = viewModel::host,
+ onConnectClicked = viewModel::connect,
+ )
+ }
+ }
+ }
+
+ override fun setTheme(themeId: Int) {
+ super.setTheme(themeId)
+ this.themeId = themeId
+ }
+
+ override fun onResume() {
+ ThemeHelper.setCorrectTheme(this)
+ super.onResume()
+ }
+
+ companion object {
+ @JvmStatic
+ fun launch(context: Context) {
+ context.startActivity(Intent(context, NetplaySetupActivity::class.java))
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt
new file mode 100644
index 0000000000..f7f660638d
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt
@@ -0,0 +1,415 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package org.dolphinemu.dolphinemu.features.netplay.ui
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+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
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MediumTopAppBar
+import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SecondaryTabRow
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import org.dolphinemu.dolphinemu.R
+import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionRole
+import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType
+import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme
+import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer
+
+private data class ErrorDialogState(val message: String) {
+ val onDismissed = CompletableDeferred<Unit>()
+}
+
+@Composable
+fun NetplaySetupScreen(
+ onBackClicked: () -> Unit,
+ connecting: Boolean,
+ errors: Flow<String>,
+ connectionRole: ConnectionRole,
+ onConnectionRoleChanged: (ConnectionRole) -> Unit,
+ nickname: String,
+ onNicknameChanged: (String) -> Unit,
+ connectionType: ConnectionType,
+ onConnectionTypeChanged: (ConnectionType) -> Unit,
+ ipAddress: String,
+ onIpAddressChanged: (String) -> Unit,
+ connectPort: String,
+ onConnectPortChanged: (String) -> Unit,
+ hostCode: String,
+ onHostCodeChanged: (String) -> Unit,
+ hostPort: String,
+ onHostPortChanged: (String) -> Unit,
+ useUpnp: Boolean,
+ onUseUpnpChanged: (Boolean) -> Unit,
+ onHostClicked: () -> Unit,
+ onConnectClicked: () -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ MediumTopAppBar(
+ title = { Text(stringResource(R.string.netplay_setup_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBackClicked) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back"
+ )
+ }
+ },
+ )
+ },
+ floatingActionButton = {
+ ExtendedFloatingActionButton(
+ onClick = when(connectionRole) {
+ ConnectionRole.Host -> onHostClicked
+ ConnectionRole.Connect -> onConnectClicked
+ },
+ ) {
+ if (connecting) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Spacer(Modifier.width(12.dp))
+ Text(stringResource(connectionRole.loadingLabelId))
+ } else {
+ Text(stringResource(connectionRole.labelId))
+ }
+ }
+ }
+ ) { innerPadding ->
+ var activeErrorDialog by remember { mutableStateOf<ErrorDialogState?>(null) }
+ LaunchedEffect(Unit) {
+ errors.collect { message ->
+ activeErrorDialog = ErrorDialogState(message)
+ activeErrorDialog?.onDismissed?.await()
+ activeErrorDialog = null
+ }
+ }
+ activeErrorDialog?.let { activeErrorDialog ->
+ AlertDialog(
+ text = { Text(activeErrorDialog.message) },
+ confirmButton = {
+ TextButton(onClick = { activeErrorDialog.onDismissed.complete(Unit) }) {
+ Text("Dismiss")
+ }
+ },
+ onDismissRequest = { activeErrorDialog.onDismissed.complete(Unit) },
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .consumeWindowInsets(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .padding(innerPadding)
+ .padding(bottom = DolphinTheme.fabClearancePadding)
+ ) {
+ SecondaryTabRow(selectedTabIndex = ConnectionRole.all.indexOf(connectionRole)) {
+ ConnectionRole.all.forEach { role ->
+ Tab(
+ selected = connectionRole == role,
+ onClick = { onConnectionRoleChanged(role) },
+ text = { Text(stringResource(role.labelId)) },
+ )
+ }
+ }
+
+ MenuSpacer()
+
+ Column(
+ modifier = Modifier
+ .padding(horizontal = DolphinTheme.scaffoldPadding)
+ ) {
+ NetplaySetupContent(
+ nickname = nickname,
+ onNicknameChanged = onNicknameChanged,
+ connectionType = connectionType,
+ onConnectionTypeChanged = onConnectionTypeChanged,
+ ipAddress = ipAddress,
+ onIpAddressChanged = onIpAddressChanged,
+ hostCode = hostCode,
+ onHostCodeChanged = onHostCodeChanged,
+ connectPort = connectPort,
+ onConnectPortChanged = onConnectPortChanged,
+ hostPort = hostPort,
+ onHostPortChanged = onHostPortChanged,
+ useUpnp = useUpnp,
+ onUseUpnpChanged = onUseUpnpChanged,
+ connectionRole = connectionRole,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun NetplaySetupContent(
+ nickname: String,
+ onNicknameChanged: (String) -> Unit,
+ connectionType: ConnectionType,
+ onConnectionTypeChanged: (ConnectionType) -> Unit,
+ ipAddress: String,
+ onIpAddressChanged: (String) -> Unit,
+ hostCode: String,
+ onHostCodeChanged: (String) -> Unit,
+ connectPort: String,
+ onConnectPortChanged: (String) -> Unit,
+ hostPort: String,
+ onHostPortChanged: (String) -> Unit,
+ useUpnp: Boolean,
+ onUseUpnpChanged: (Boolean) -> Unit,
+ connectionRole: ConnectionRole,
+) {
+ OutlinedTextField(
+ value = nickname,
+ onValueChange = onNicknameChanged,
+ label = { Text(stringResource(R.string.netplay_nickname_label)) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ MenuSpacer()
+
+ ConnectionTypePicker(
+ connectionType = connectionType,
+ onConnectionTypeChanged = onConnectionTypeChanged,
+ )
+
+ MenuSpacer()
+
+ when (connectionRole) {
+ ConnectionRole.Connect -> ConnectMenu(
+ connectionType = connectionType,
+ ipAddress = ipAddress,
+ onIpAddressChanged = onIpAddressChanged,
+ hostCode = hostCode,
+ onHostCodeChanged = onHostCodeChanged,
+ port = connectPort,
+ onPortChanged = onConnectPortChanged,
+ )
+
+ ConnectionRole.Host -> HostMenu(
+ connectionType = connectionType,
+ hostPort = hostPort,
+ onHostPortChanged = onHostPortChanged,
+ useUpnp = useUpnp,
+ onUseUpnpChanged = onUseUpnpChanged,
+ )
+ }
+}
+
+@Composable
+private fun ConnectionTypePicker(
+ connectionType: ConnectionType,
+ onConnectionTypeChanged: (ConnectionType) -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ ) {
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ ConnectionType.all.forEach { connectionType ->
+ DropdownMenuItem(
+ text = { Text(stringResource(connectionType.labelId)) },
+ onClick = {
+ onConnectionTypeChanged(connectionType)
+ expanded = false
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
+ )
+ }
+ }
+ OutlinedTextField(
+ value = stringResource(connectionType.labelId),
+ onValueChange = {},
+ readOnly = true,
+ label = { Text(stringResource(R.string.netplay_connection_type)) },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
+ modifier = Modifier
+ .menuAnchor(MenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth()
+ )
+ }
+}
+
+@Composable
+fun ConnectMenu(
+ connectionType: ConnectionType,
+ ipAddress: String,
+ onIpAddressChanged: (String) -> Unit,
+ hostCode: String,
+ onHostCodeChanged: (String) -> Unit,
+ port: String,
+ onPortChanged: (String) -> Unit,
+) {
+ when (connectionType) {
+ ConnectionType.DirectConnection -> {
+ OutlinedTextField(
+ value = ipAddress,
+ onValueChange = onIpAddressChanged,
+ label = { Text(stringResource(R.string.netplay_ip_address_label)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ ),
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ )
+
+ MenuSpacer()
+
+ OutlinedTextField(
+ value = port,
+ onValueChange = onPortChanged,
+ label = { Text(stringResource(R.string.netplay_port_label)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ ),
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ )
+ }
+
+ ConnectionType.TraversalServer -> OutlinedTextField(
+ value = hostCode,
+ onValueChange = onHostCodeChanged,
+ label = { Text(stringResource(R.string.netplay_host_code_label)) },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ )
+ }
+}
+
+@Composable
+private fun HostMenu(
+ connectionType: ConnectionType,
+ hostPort: String,
+ onHostPortChanged: (String) -> Unit,
+ useUpnp: Boolean,
+ onUseUpnpChanged: (Boolean) -> Unit,
+) {
+ if (connectionType == ConnectionType.DirectConnection) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ OutlinedTextField(
+ value = hostPort,
+ onValueChange = onHostPortChanged,
+ label = { Text(stringResource(R.string.netplay_host_port_label)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ ),
+ singleLine = true,
+ modifier = Modifier
+ .weight(1f)
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ OutlinedButton(
+ onClick = { onUseUpnpChanged(!useUpnp) },
+ shape = MaterialTheme.shapes.extraSmall,
+ modifier = Modifier
+ .height(64.dp)
+ .padding(top = 8.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.netplay_use_upnp),
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Checkbox(
+ checked = useUpnp,
+ onCheckedChange = null,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+
+ MenuSpacer()
+ }
+}
+
+@Preview
+@Composable
+private fun NetplaySetupScreenPreview() {
+ MaterialTheme {
+ NetplaySetupScreen(
+ onBackClicked = {},
+ connecting = false,
+ errors = emptyFlow(),
+ connectionRole = ConnectionRole.Host,
+ onConnectionRoleChanged = {},
+ nickname = "Preview nickname",
+ onNicknameChanged = {},
+ connectionType = ConnectionType.DirectConnection,
+ onConnectionTypeChanged = {},
+ ipAddress = "127.0.0.1",
+ onIpAddressChanged = {},
+ connectPort = "2626",
+ onConnectPortChanged = {},
+ hostCode = "",
+ onHostCodeChanged = {},
+ hostPort = "2626",
+ onHostPortChanged = {},
+ useUpnp = false,
+ onUseUpnpChanged = {},
+ onHostClicked = {},
+ onConnectClicked = {},
+ )
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt
index 8edd5bd26c..1a03eac3f4 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt
@@ -934,7 +934,8 @@ enum class BooleanSetting(
Settings.SECTION_ACHIEVEMENTS,
"ProgressEnabled",
false
- );
+ ),
+ NETPLAY_USE_UPNP(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "UseUPNP", false);
override val isOverridden: Boolean
get() = NativeConfig.isOverridden(file, section, key)
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt
index 1802357e0d..e3430c9527 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt
@@ -162,7 +162,21 @@ enum class IntSetting(
WIIMOTE_2_SOURCE(Settings.FILE_WIIMOTE, "Wiimote2", "Source", 0),
WIIMOTE_3_SOURCE(Settings.FILE_WIIMOTE, "Wiimote3", "Source", 0),
WIIMOTE_4_SOURCE(Settings.FILE_WIIMOTE, "Wiimote4", "Source", 0),
- WIIMOTE_BB_SOURCE(Settings.FILE_WIIMOTE, "BalanceBoard", "Source", 0);
+ WIIMOTE_BB_SOURCE(Settings.FILE_WIIMOTE, "BalanceBoard", "Source", 0),
+ NETPLAY_CONNECT_PORT(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "ConnectPort", 2626),
+ NETPLAY_HOST_PORT(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "HostPort", 2626),
+ NETPLAY_CLIENT_BUFFER_SIZE(
+ Settings.FILE_DOLPHIN,
+ Settings.SECTION_INI_NETPLAY,
+ "BufferSizeClient",
+ 1
+ ),
+ NETPLAY_BUFFER_SIZE(
+ Settings.FILE_DOLPHIN,
+ Settings.SECTION_INI_NETPLAY,
+ "BufferSize",
+ 5
+ );
override val isOverridden: Boolean
get() = NativeConfig.isOverridden(file, section, key)
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt
index 96d40b214c..da54d81258 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt
@@ -112,6 +112,7 @@ class Settings : Closeable {
const val SECTION_INI_INTERFACE = "Interface"
const val SECTION_INI_DSP = "DSP"
const val SECTION_INI_GBA = "GBA"
+ const val SECTION_INI_NETPLAY = "NetPlay"
const val SECTION_LOGGER_LOGS = "Logs"
const val SECTION_LOGGER_OPTIONS = "Options"
const val SECTION_GFX_HARDWARE = "Hardware"
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt
index 512a33ddbb..72877429af 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt
@@ -101,7 +101,23 @@ enum class StringSetting(
Settings.SECTION_ACHIEVEMENTS,
"ApiToken",
""
- );
+ ),
+ NETPLAY_TRAVERSAL_CHOICE(
+ Settings.FILE_DOLPHIN,
+ Settings.SECTION_INI_NETPLAY,
+ "TraversalChoice",
+ "direct"
+ ),
+ NETPLAY_HOST_CODE(
+ Settings.FILE_DOLPHIN,
+ Settings.SECTION_INI_NETPLAY,
+ "HostCode",
+ ""
+ ),
+ NETPLAY_ADDRESS(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Address", "127.0.0.1"),
+ NETPLAY_NICKNAME(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Nickname", "Player"),
+ NETPLAY_GAME(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Game", ""),
+ NETPLAY_NETWORK_MODE(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "NetworkMode", "fixeddelay");
override val isOverridden: Boolean
get() = NativeConfig.isOverridden(file, section, key)
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt
index e4698ba43c..a005995bdb 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt
@@ -9,10 +9,17 @@ import android.view.LayoutInflater
import android.view.SurfaceHolder
import android.view.View
import android.view.ViewGroup
+import android.widget.Toast
import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import org.dolphinemu.dolphinemu.NativeLibrary
import org.dolphinemu.dolphinemu.activities.EmulationActivity
import org.dolphinemu.dolphinemu.databinding.FragmentEmulationBinding
+import org.dolphinemu.dolphinemu.features.netplay.NetplayManager
import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting
import org.dolphinemu.dolphinemu.features.settings.model.Settings
import org.dolphinemu.dolphinemu.overlay.InputOverlay
@@ -204,7 +211,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
if (NativeLibrary.IsUninitialized()) {
NativeLibrary.SetIsBooting()
val emulationThread = Thread({
- if (loadPreviousTemporaryState) {
+ // Don't load temporary saves when launching Netplay, this path can trigger
+ // when a game starts due to orientation changes caused by a mismatch in menu
+ // vs emulation activity orientations.
+ val netplaySession = NetplayManager.activeSession
+ if (loadPreviousTemporaryState && netplaySession?.isLaunching != true) {
Log.debug("[EmulationFragment] Starting emulation thread from previous state.")
val paths = requireNotNull(gamePaths) {
"Cannot start emulation without any game paths"
@@ -214,6 +225,24 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
if (launchSystemMenu) {
Log.debug("[EmulationFragment] Starting emulation thread for the Wii Menu.")
NativeLibrary.RunSystemMenu()
+ } else if (netplaySession?.isLaunching == true) {
+ Log.debug("[EmulationFragment] Starting emulation thread for Netplay.")
+ val paths = requireNotNull(gamePaths) {
+ "Cannot start emulation without any game paths"
+ }
+ lifecycleScope.launch {
+ netplaySession.stopGame.first()
+ stopEmulation()
+ }
+ netplaySession
+ .desyncMessages
+ .onEach { Toast.makeText(requireContext(), it.message(requireContext()), Toast.LENGTH_SHORT).show() }
+ .launchIn(lifecycleScope)
+ NativeLibrary.RunNetPlay(
+ paths,
+ riivolution,
+ netplaySession.consumeBootSessionData()
+ )
} else {
Log.debug("[EmulationFragment] Starting emulation thread.")
val paths = requireNotNull(gamePaths) {
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt
index 893d6a16d4..1e1d55dbd7 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt
@@ -21,6 +21,7 @@ import org.dolphinemu.dolphinemu.features.settings.ui.MenuTag
import org.dolphinemu.dolphinemu.features.sysupdate.ui.SystemMenuNotInstalledDialogFragment
import org.dolphinemu.dolphinemu.features.sysupdate.ui.SystemUpdateProgressBarDialogFragment
import org.dolphinemu.dolphinemu.features.sysupdate.ui.SystemUpdateViewModel
+import org.dolphinemu.dolphinemu.features.netplay.ui.NetplaySetupActivity
import org.dolphinemu.dolphinemu.fragments.AboutDialogFragment
import org.dolphinemu.dolphinemu.model.GameFileCache
import org.dolphinemu.dolphinemu.services.GameFileCacheManager
@@ -188,6 +189,11 @@ class MainPresenter(private val mainView: MainView, private val activity: Fragme
true
}
+ R.id.menu_netplay -> {
+ NetplaySetupActivity.launch(activity)
+ true
+ }
+
R.id.menu_about -> {
showAboutDialog()
false
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt
new file mode 100644
index 0000000000..c142a44d73
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt
@@ -0,0 +1,337 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.ui.theme
+
+import android.content.Context
+import androidx.annotation.AttrRes
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.SheetValue.Hidden
+import androidx.compose.material3.Text
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.google.android.material.color.MaterialColors
+import androidx.appcompat.R as AppCompatR
+import com.google.android.material.R as MaterialR
+
+object DolphinTheme {
+ val scaffoldPadding = 16.dp
+ val fabClearancePadding = 80.dp
+}
+
+@Composable
+fun DolphinTheme(content: @Composable () -> Unit) {
+ val context = LocalContext.current
+ val isDark = isSystemInDarkTheme()
+ val colorScheme = remember(context, isDark) { context.toDolphinColorScheme(isDark) }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ content = content
+ )
+}
+
+@Composable
+fun PreviewTheme(
+ darkTheme: Boolean,
+ content: @Composable () -> Unit,
+) {
+ MaterialTheme(
+ colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(),
+ content = content
+ )
+}
+
+private fun Context.toDolphinColorScheme(isDark: Boolean): ColorScheme {
+ fun attr(@AttrRes attr: Int) = Color(MaterialColors.getColor(this, attr, 0))
+
+ val background = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground)).use {
+ Color(it.getColor(0, 0))
+ }
+
+ return if (isDark) {
+ darkColorScheme(
+ primary = attr(AppCompatR.attr.colorPrimary),
+ onPrimary = attr(MaterialR.attr.colorOnPrimary),
+ primaryContainer = attr(MaterialR.attr.colorPrimaryContainer),
+ onPrimaryContainer = attr(MaterialR.attr.colorOnPrimaryContainer),
+ secondary = attr(MaterialR.attr.colorSecondary),
+ onSecondary = attr(MaterialR.attr.colorOnSecondary),
+ secondaryContainer = attr(MaterialR.attr.colorSecondaryContainer),
+ onSecondaryContainer = attr(MaterialR.attr.colorOnSecondaryContainer),
+ tertiary = attr(MaterialR.attr.colorTertiary),
+ onTertiary = attr(MaterialR.attr.colorOnTertiary),
+ tertiaryContainer = attr(MaterialR.attr.colorTertiaryContainer),
+ onTertiaryContainer = attr(MaterialR.attr.colorOnTertiaryContainer),
+ error = attr(AppCompatR.attr.colorError),
+ onError = attr(MaterialR.attr.colorOnError),
+ errorContainer = attr(MaterialR.attr.colorErrorContainer),
+ onErrorContainer = attr(MaterialR.attr.colorOnErrorContainer),
+ background = background,
+ onBackground = attr(MaterialR.attr.colorOnBackground),
+ surface = attr(MaterialR.attr.colorSurface),
+ onSurface = attr(MaterialR.attr.colorOnSurface),
+ surfaceVariant = attr(MaterialR.attr.colorSurfaceVariant),
+ onSurfaceVariant = attr(MaterialR.attr.colorOnSurfaceVariant),
+ outline = attr(MaterialR.attr.colorOutline),
+ inverseSurface = attr(MaterialR.attr.colorSurfaceInverse),
+ inverseOnSurface = attr(MaterialR.attr.colorOnSurfaceInverse),
+ inversePrimary = attr(MaterialR.attr.colorPrimaryInverse),
+ )
+ } else {
+ lightColorScheme(
+ primary = attr(AppCompatR.attr.colorPrimary),
+ onPrimary = attr(MaterialR.attr.colorOnPrimary),
+ primaryContainer = attr(MaterialR.attr.colorPrimaryContainer),
+ onPrimaryContainer = attr(MaterialR.attr.colorOnPrimaryContainer),
+ secondary = attr(MaterialR.attr.colorSecondary),
+ onSecondary = attr(MaterialR.attr.colorOnSecondary),
+ secondaryContainer = attr(MaterialR.attr.colorSecondaryContainer),
+ onSecondaryContainer = attr(MaterialR.attr.colorOnSecondaryContainer),
+ tertiary = attr(MaterialR.attr.colorTertiary),
+ onTertiary = attr(MaterialR.attr.colorOnTertiary),
+ tertiaryContainer = attr(MaterialR.attr.colorTertiaryContainer),
+ onTertiaryContainer = attr(MaterialR.attr.colorOnTertiaryContainer),
+ error = attr(AppCompatR.attr.colorError),
+ onError = attr(MaterialR.attr.colorOnError),
+ errorContainer = attr(MaterialR.attr.colorErrorContainer),
+ onErrorContainer = attr(MaterialR.attr.colorOnErrorContainer),
+ background = background,
+ onBackground = attr(MaterialR.attr.colorOnBackground),
+ surface = attr(MaterialR.attr.colorSurface),
+ onSurface = attr(MaterialR.attr.colorOnSurface),
+ surfaceVariant = attr(MaterialR.attr.colorSurfaceVariant),
+ onSurfaceVariant = attr(MaterialR.attr.colorOnSurfaceVariant),
+ outline = attr(MaterialR.attr.colorOutline),
+ inverseSurface = attr(MaterialR.attr.colorSurfaceInverse),
+ inverseOnSurface = attr(MaterialR.attr.colorOnSurfaceInverse),
+ inversePrimary = attr(MaterialR.attr.colorPrimaryInverse),
+ )
+ }
+}
+
+@Composable
+fun MenuSpacer() = Spacer(modifier = Modifier.height(16.dp))
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OutlinedBox(
+ label: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ onClick: (() -> Unit)? = null,
+ fadeContentTop: Boolean = false,
+ content: @Composable () -> Unit,
+) {
+ Box(
+ modifier = modifier
+ .padding(top = 8.dp)
+ ) {
+ val interactionSource = remember { MutableInteractionSource() }
+ OutlinedTextFieldDefaults.DecorationBox(
+ value = "chatText",
+ innerTextField = {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ content()
+ if (fadeContentTop) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(16.dp)
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.surface,
+ Color.Transparent
+ )
+ )
+ )
+ )
+ }
+ }
+ },
+ enabled = true,
+ singleLine = false,
+ contentPadding = if (fadeContentTop) {
+ OutlinedTextFieldDefaults.contentPadding(top = 0.dp)
+ } else {
+ OutlinedTextFieldDefaults.contentPadding()
+ },
+ visualTransformation = VisualTransformation.None,
+ interactionSource = interactionSource,
+ label = { label() },
+ container = {
+ OutlinedTextFieldDefaults.Container(
+ enabled = true,
+ isError = false,
+ interactionSource = interactionSource,
+ colors = OutlinedTextFieldDefaults.colors(),
+ )
+ }
+ )
+ if (onClick != null) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(MaterialTheme.shapes.extraSmall)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = LocalIndication.current,
+ onClick = onClick,
+ )
+ )
+ }
+ }
+}
+
+@Composable
+fun ReadOnlyTextField(
+ value: String,
+ label: String,
+ modifier: Modifier = Modifier,
+ onClick: (() -> Unit)? = null,
+ trailingIcon: (@Composable () -> Unit)? = null,
+ textStyle: TextStyle? = null,
+) {
+ Box(modifier = modifier) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = {},
+ readOnly = true,
+ singleLine = true,
+ label = { Text(label) },
+ trailingIcon = trailingIcon,
+ textStyle = textStyle ?: LocalTextStyle.current,
+ modifier = Modifier.fillMaxWidth()
+ )
+ if (onClick != null) {
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .padding(top = 8.dp)
+ .clip(MaterialTheme.shapes.extraSmall)
+ .clickable(onClick = onClick)
+ )
+ }
+ }
+}
+
+// A copy-paste of the internal function used in rememberModalBottomSheetState since
+// rememberModalBottomSheetState doesn't expose a way to set the initial value.
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun rememberSheetState(
+ skipPartiallyExpanded: Boolean = false,
+ confirmValueChange: (SheetValue) -> Boolean = { true },
+ initialValue: SheetValue = Hidden,
+ skipHiddenState: Boolean = false,
+ positionalThreshold: Dp = 56.dp,
+ velocityThreshold: Dp = 125.dp,
+): SheetState {
+ val density = LocalDensity.current
+ val positionalThresholdToPx = { with(density) { positionalThreshold.toPx() } }
+ val velocityThresholdToPx = { with(density) { velocityThreshold.toPx() } }
+ return rememberSaveable(
+ skipPartiallyExpanded,
+ confirmValueChange,
+ skipHiddenState,
+ saver =
+ SheetState.Saver(
+ skipPartiallyExpanded = skipPartiallyExpanded,
+ positionalThreshold = positionalThresholdToPx,
+ velocityThreshold = velocityThresholdToPx,
+ confirmValueChange = confirmValueChange,
+ skipHiddenState = skipHiddenState,
+ ),
+ ) {
+ SheetState(
+ skipPartiallyExpanded,
+ positionalThresholdToPx,
+ velocityThresholdToPx,
+ initialValue,
+ confirmValueChange,
+ skipHiddenState,
+ )
+ }
+}
+
+@Composable
+fun Modifier.bottomFadeOverlay(
+ scrollState: ScrollState,
+ fadeHeight: Dp,
+): Modifier {
+ val surfaceColor = MaterialTheme.colorScheme.surface
+ val bottomPaddingPx = with(LocalDensity.current) {
+ fadeHeight.toPx()
+ }
+ val fadeDelayPx = with(LocalDensity.current) { 8.dp.toPx() }
+ val fadeDurationPx = with(LocalDensity.current) { 48.dp.toPx() }
+ val scrollProgress by remember {
+ derivedStateOf {
+ if (bottomPaddingPx <= 0f) 1f
+ else ((scrollState.value.coerceAtLeast(0) - fadeDelayPx) / fadeDurationPx)
+ .coerceIn(0f, 1f)
+ }
+ }
+ val surfaceStopFraction = (scrollProgress * 2f).coerceIn(0f, 1f)
+ val transparentStopFraction = ((scrollProgress * 2f) - 1f).coerceIn(0f, 1f)
+
+ return drawWithContent {
+ drawContent()
+ if (transparentStopFraction < 1f) {
+ drawRect(
+ brush = Brush.verticalGradient(
+ colorStops = arrayOf(
+ 0f to Color.Transparent,
+ transparentStopFraction to Color.Transparent,
+ surfaceStopFraction to surfaceColor,
+ 1f to surfaceColor,
+ ),
+ startY = size.height - bottomPaddingPx,
+ endY = size.height,
+ ),
+ topLeft = Offset(0f, size.height - bottomPaddingPx),
+ size = Size(size.width, bottomPaddingPx),
+ )
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt
index 846fcd564e..8c3fb2e3f3 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt
@@ -66,7 +66,7 @@ class GameCoverKeyer : Keyer<GameFile> {
}
object CoilUtils {
- private val imageLoader = ImageLoader.Builder(DolphinApplication.getAppContext())
+ val imageLoader = ImageLoader.Builder(DolphinApplication.getAppContext())
.components {
add(GameCoverKeyer())
add(GameCoverFetcher.Factory())
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt
index 7bd2068ee2..231f15fda1 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt
@@ -75,4 +75,9 @@ object NetworkHelper {
0
}
}
+
+ @JvmStatic
+ fun getLocalIpString(): String? {
+ return getIPv4Link()?.address?.hostAddress
+ }
}