Merge pull request #14647 from tom-pratt/netplay

Android netplay
This commit is contained in:
JMC47
2026-06-28 12:38:55 -04:00
committed by GitHub
44 changed files with 4580 additions and 27 deletions

View File

@@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.androidx.baselineprofile)
}
@@ -10,6 +11,7 @@ android {
ndkVersion = "29.0.14206865"
buildFeatures {
compose = true
viewBinding = true
buildConfig = true
resValues = true
@@ -116,6 +118,12 @@ android {
}
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xannotation-default-target=param-property")
}
}
dependencies {
baselineProfile(project(":benchmark"))
coreLibraryDesugaring(libs.desugar.jdk.libs)
@@ -133,6 +141,7 @@ dependencies {
implementation(libs.androidx.profileinstaller)
// Kotlin extensions for lifecycle components
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -143,6 +152,7 @@ dependencies {
// For loading game covers from disk and GameTDB
implementation(libs.coil)
implementation(libs.coil.compose)
// For loading custom GPU drivers
implementation(libs.kotlinx.serialization.json)
@@ -150,6 +160,17 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.filepicker)
// Jetpack Compose
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material.icons)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.runtime.livedata)
implementation(libs.androidx.compose.ui)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.tooling.preview)
}
fun getGitVersion(): String {

View File

@@ -133,6 +133,16 @@
android:label="@string/user_data_submenu"
android:theme="@style/Theme.Dolphin.Main" />
<activity
android:name=".features.netplay.ui.NetplaySetupActivity"
android:exported="false"
android:theme="@style/Theme.Dolphin.Main" />
<activity
android:name=".features.netplay.ui.NetplayActivity"
android:exported="false"
android:theme="@style/Theme.Dolphin.Main" />
<activity
android:name=".features.riivolution.ui.RiivolutionBootActivity"
android:exported="false"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -75,4 +75,9 @@ object NetworkHelper {
0
}
}
@JvmStatic
fun getLocalIpString(): String? {
return getIPv4Link()?.address?.hostAddress
}
}

View File

@@ -51,6 +51,11 @@
android:title="@string/grid_menu_online_system_update"
app:showAsAction="never"/>
<item
android:id="@+id/menu_netplay"
android:title="@string/grid_menu_netplay"
app:showAsAction="never"/>
<item
android:id="@+id/menu_about"
android:title="@string/grid_menu_about"

View File

@@ -506,6 +506,7 @@
<string name="grid_menu_import_wii_save">Import Wii Save</string>
<string name="grid_menu_import_nand_backup">Import BootMii NAND Backup</string>
<string name="grid_menu_online_system_update">Perform Online System Update</string>
<string name="grid_menu_netplay">Netplay</string>
<string name="grid_menu_load_wii_system_menu">Load Wii System Menu</string>
<string name="grid_menu_load_wii_system_menu_installed">Load Wii System Menu (%s)</string>
<string name="grid_menu_load_vwii_system_menu_installed">Load vWii System Menu (%s)</string>
@@ -991,4 +992,59 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="achievements_logout">Log Out</string>
<string name="achievements_login_in_progress">Logging In</string>
<string name="achievements_login_failed">Login Failed</string>
<!-- Netplay -->
<string name="netplay_setup_title">Netplay Setup</string>
<string name="netplay_connection_type">Connection type</string>
<string name="netplay_connection_type_direct_connection">Direct connection</string>
<string name="netplay_connection_type_traversal_server">Traversal server</string>
<string name="netplay_connection_role_connect">Connect</string>
<string name="netplay_connection_role_connect_loading">Connecting…</string>
<string name="netplay_connection_role_host">Host</string>
<string name="netplay_connection_role_host_loading">Starting…</string>
<string name="netplay_nickname_label">Nickname</string>
<string name="netplay_ip_address_label">IP address</string>
<string name="netplay_host_code_label">Host code</string>
<string name="netplay_port_label">Port</string>
<string name="netplay_title">Netplay</string>
<string name="netplay_start">Start</string>
<string name="netplay_start_warning_title">Warning</string>
<string name="netplay_start_warning_not_all_players_have_game">Not all players have the game. Do you really want to start?</string>
<string name="netplay_chat_label">Chat</string>
<string name="netplay_chat_send">Send</string>
<string name="netplay_message_game_changed">Game changed to %1$s</string>
<string name="netplay_message_buffer_changed">Buffer size changed to %1$d</string>
<string name="netplay_message_host_input_authority_changed">"Host input authority %1$s"</string>
<string name="netplay_message_desync">Possible desync detected: %1$s might have desynced at frame %2$d</string>
<string name="netplay_game_label">Game</string>
<string name="netplay_players_label">Players</string>
<string name="netplay_players_name">Name</string>
<string name="netplay_players_ping">Ping</string>
<string name="netplay_players_mapping">Mapping</string>
<string name="netplay_network_mode_label">Input mode</string>
<string name="netplay_network_mode_fair_input_delay">Fair Input Delay</string>
<string name="netplay_network_mode_host_input_authority">Host Input Authority</string>
<string name="netplay_network_mode_golf">Golf Mode</string>
<string name="netplay_buffer">Buffer</string>
<string name="netplay_client_buffer">Max buffer</string>
<string name="netplay_connection_lost">Netplay connection lost</string>
<string name="netplay_save_transfer_progress_close">Close</string>
<string name="netplay_game_digest_match">The hashes match</string>
<string name="netplay_game_digest_mismatch">The hashes do not match</string>
<string name="netplay_game_digest_close">Close</string>
<string name="netplay_host_port_label">Port</string>
<string name="netplay_use_upnp">Forward port (UPnP)</string>
<string name="netplay_host_address_label">Join info</string>
<string name="netplay_address_label">Address</string>
<string name="netplay_code_label">Code</string>
<string name="netplay_address_type_room_id">Room</string>
<string name="netplay_address_type_external">External</string>
<string name="netplay_address_type_local">Local</string>
<string name="netplay_traversal_error_bad_host">Couldn\'t look up central server</string>
<string name="netplay_traversal_error_version_too_old">Dolphin is too old for traversal server</string>
<string name="netplay_address_loading">Loading…</string>
<string name="netplay_address_unknown">Unknown</string>
<string name="netplay_address_share">Share address</string>
<string name="netplay_address_retry">Retry</string>
<string name="netplay_transfer_progress">%1$s/%2$s MiB</string>
</resources>