mirror of
https://github.com/dolphin-emu/dolphin.git
synced 2026-05-09 04:13:28 -05:00
Merge fb03278049 into d19952cc11
This commit is contained in:
commit
08cd85cf8b
|
|
@ -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,16 @@ 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.runtime.livedata)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling.preview)
|
||||
}
|
||||
|
||||
fun getGitVersion(): String {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
// Copyright 2003 Dolphin Emulator Project
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,439 @@
|
|||
// Copyright 2003 Dolphin Emulator Project
|
||||
// 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
|
||||
|
||||
class NetplaySession(
|
||||
private val onClosed: (NetplaySession) -> Unit,
|
||||
) {
|
||||
|
||||
private var netPlayUICallbacksPointer: Long = nativeCreateUICallbacks()
|
||||
|
||||
private var netPlayClientPointer: Long = 0
|
||||
|
||||
private var netPlayServerPointer: Long = 0
|
||||
|
||||
private var bootSessionDataPointer: Long = 0
|
||||
|
||||
private val sessionScope = CoroutineScope(SupervisorJob())
|
||||
|
||||
@Volatile
|
||||
var isClosing = 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) {
|
||||
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) {
|
||||
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 (isClosing) return
|
||||
isClosing = 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(): 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
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
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?,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 _startGameWarning = Channel<Unit>(Channel.CONFLATED)
|
||||
val startGameWarning = _startGameWarning.receiveAsFlow()
|
||||
|
||||
fun startGame() {
|
||||
if (netplaySession.doAllPlayersHaveGame()) {
|
||||
netplaySession.startGame()
|
||||
} else {
|
||||
_startGameWarning.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright 2003 Dolphin Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay.model
|
||||
|
||||
data class Player(
|
||||
val pid: Int,
|
||||
val name: String,
|
||||
val revision: String,
|
||||
val ping: Int,
|
||||
val isHost: Boolean,
|
||||
val mapping: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
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,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// 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.compose.runtime.livedata.observeAsState
|
||||
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,
|
||||
startGameWarning = viewModel.startGameWarning,
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
@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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -933,7 +933,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)
|
||||
|
|
|
|||
|
|
@ -138,7 +138,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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,288 @@
|
|||
// 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.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.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.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -75,4 +75,9 @@ object NetworkHelper {
|
|||
0
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getLocalIpString(): String? {
|
||||
return getIPv4Link()?.address?.hostAddress
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -495,6 +495,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>
|
||||
|
|
@ -980,4 +981,58 @@ 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>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ plugins {
|
|||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.android.test) apply false
|
||||
alias(libs.plugins.androidx.baselineprofile) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
}
|
||||
|
||||
buildscript {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ appcompat = "1.7.1"
|
|||
benchmarkMacroJunit4 = "1.5.0-alpha04"
|
||||
cardview = "1.0.0"
|
||||
coil = "2.7.0"
|
||||
compose-bom = "2026.04.01"
|
||||
constraintlayout = "2.2.1"
|
||||
coreKtx = "1.18.0"
|
||||
coreSplashscreen = "1.2.0"
|
||||
|
|
@ -27,9 +28,17 @@ tvprovider = "1.1.0"
|
|||
uiautomator = "2.3.0"
|
||||
|
||||
[libraries]
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" }
|
||||
androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
|
||||
androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }
|
||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" }
|
||||
|
|
@ -37,6 +46,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
|
|||
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" }
|
||||
androidx-leanback = { group = "androidx.leanback", name = "leanback", version.ref = "leanback" }
|
||||
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
|
||||
androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
|
||||
|
|
@ -47,6 +57,7 @@ androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "s
|
|||
androidx-tvprovider = { group = "androidx.tvprovider", name = "tvprovider", version.ref = "tvprovider" }
|
||||
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
|
||||
coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
desugar_jdk_libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
||||
filepicker = { group = "com.nononsenseapps", name = "filepicker", version.ref = "filepicker" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
|
||||
|
|
@ -58,4 +69,5 @@ android-application = { id = "com.android.application", version.ref = "agp" }
|
|||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
android-test = { id = "com.android.test", version.ref = "agp" }
|
||||
androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmarkMacroJunit4" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
|
|
|
|||
|
|
@ -25,6 +25,35 @@ static jmethodID s_game_file_constructor;
|
|||
static jclass s_game_file_cache_class;
|
||||
static jfieldID s_game_file_cache_pointer;
|
||||
|
||||
static jclass s_game_file_cache_manager_class;
|
||||
static jfieldID s_game_file_cache_manager_instance;
|
||||
|
||||
static jclass s_netplay_class;
|
||||
static jfieldID s_net_play_ui_callbacks_pointer;
|
||||
static jfieldID s_net_play_client_pointer;
|
||||
static jfieldID s_net_play_server_pointer;
|
||||
static jmethodID s_netplay_on_boot_game;
|
||||
static jmethodID s_netplay_on_stop_game;
|
||||
static jmethodID s_netplay_on_connection_lost;
|
||||
static jmethodID s_netplay_on_connection_error;
|
||||
static jmethodID s_netplay_on_game_changed;
|
||||
static jmethodID s_netplay_on_host_input_authority_changed;
|
||||
static jmethodID s_netplay_on_pad_buffer_changed;
|
||||
static jmethodID s_netplay_on_chat_message_received;
|
||||
static jmethodID s_netplay_update;
|
||||
static jmethodID s_netplay_on_show_chunked_progress_dialog;
|
||||
static jmethodID s_netplay_on_set_chunked_progress;
|
||||
static jmethodID s_netplay_on_hide_chunked_progress_dialog;
|
||||
static jmethodID s_netplay_on_desync;
|
||||
static jmethodID s_netplay_on_show_game_digest_dialog;
|
||||
static jmethodID s_netplay_on_set_game_digest_progress;
|
||||
static jmethodID s_netplay_on_set_game_digest_result;
|
||||
static jmethodID s_netplay_on_abort_game_digest;
|
||||
static jmethodID s_netplay_on_traversal_state_changed;
|
||||
|
||||
static jclass s_netplay_player_class;
|
||||
static jmethodID s_netplay_player_constructor;
|
||||
|
||||
static jclass s_analytics_class;
|
||||
static jmethodID s_get_analytics_value;
|
||||
|
||||
|
|
@ -220,6 +249,136 @@ jfieldID GetGameFileCachePointer()
|
|||
return s_game_file_cache_pointer;
|
||||
}
|
||||
|
||||
jclass GetGameFileCacheManagerClass()
|
||||
{
|
||||
return s_game_file_cache_manager_class;
|
||||
}
|
||||
|
||||
jfieldID GetGameFileCacheManagerInstance()
|
||||
{
|
||||
return s_game_file_cache_manager_instance;
|
||||
}
|
||||
|
||||
jclass GetNetplayClass()
|
||||
{
|
||||
return s_netplay_class;
|
||||
}
|
||||
|
||||
jfieldID GetNetPlayUICallbacksPointer()
|
||||
{
|
||||
return s_net_play_ui_callbacks_pointer;
|
||||
}
|
||||
|
||||
jfieldID GetNetPlayClientPointer()
|
||||
{
|
||||
return s_net_play_client_pointer;
|
||||
}
|
||||
|
||||
jfieldID GetNetPlayServerPointer()
|
||||
{
|
||||
return s_net_play_server_pointer;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnBootGame()
|
||||
{
|
||||
return s_netplay_on_boot_game;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnStopGame()
|
||||
{
|
||||
return s_netplay_on_stop_game;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnConnectionLost()
|
||||
{
|
||||
return s_netplay_on_connection_lost;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnConnectionError()
|
||||
{
|
||||
return s_netplay_on_connection_error;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnGameChanged()
|
||||
{
|
||||
return s_netplay_on_game_changed;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnHostInputAuthorityChanged()
|
||||
{
|
||||
return s_netplay_on_host_input_authority_changed;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnPadBufferChanged()
|
||||
{
|
||||
return s_netplay_on_pad_buffer_changed;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnChatMessageReceived()
|
||||
{
|
||||
return s_netplay_on_chat_message_received;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayUpdate()
|
||||
{
|
||||
return s_netplay_update;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnShowChunkedProgressDialog()
|
||||
{
|
||||
return s_netplay_on_show_chunked_progress_dialog;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnSetChunkedProgress()
|
||||
{
|
||||
return s_netplay_on_set_chunked_progress;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnHideChunkedProgressDialog()
|
||||
{
|
||||
return s_netplay_on_hide_chunked_progress_dialog;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnDesync()
|
||||
{
|
||||
return s_netplay_on_desync;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnShowGameDigestDialog()
|
||||
{
|
||||
return s_netplay_on_show_game_digest_dialog;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnSetGameDigestProgress()
|
||||
{
|
||||
return s_netplay_on_set_game_digest_progress;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnSetGameDigestResult()
|
||||
{
|
||||
return s_netplay_on_set_game_digest_result;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnAbortGameDigest()
|
||||
{
|
||||
return s_netplay_on_abort_game_digest;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayOnTraversalStateChanged()
|
||||
{
|
||||
return s_netplay_on_traversal_state_changed;
|
||||
}
|
||||
|
||||
jclass GetNetplayPlayerClass()
|
||||
{
|
||||
return s_netplay_player_class;
|
||||
}
|
||||
|
||||
jmethodID GetNetplayPlayerConstructor()
|
||||
{
|
||||
return s_netplay_player_constructor;
|
||||
}
|
||||
|
||||
jclass GetPairClass()
|
||||
{
|
||||
return s_pair_class;
|
||||
|
|
@ -615,6 +774,62 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved)
|
|||
s_game_file_cache_pointer = env->GetFieldID(game_file_cache_class, "pointer", "J");
|
||||
env->DeleteLocalRef(game_file_cache_class);
|
||||
|
||||
const jclass game_file_cache_manager_class =
|
||||
env->FindClass("org/dolphinemu/dolphinemu/services/GameFileCacheManager");
|
||||
s_game_file_cache_manager_class =
|
||||
reinterpret_cast<jclass>(env->NewGlobalRef(game_file_cache_manager_class));
|
||||
s_game_file_cache_manager_instance = env->GetStaticFieldID(
|
||||
game_file_cache_manager_class, "gameFileCache",
|
||||
"Lorg/dolphinemu/dolphinemu/model/GameFileCache;");
|
||||
env->DeleteLocalRef(game_file_cache_manager_class);
|
||||
|
||||
const jclass netplay_class =
|
||||
env->FindClass("org/dolphinemu/dolphinemu/features/netplay/NetplaySession");
|
||||
s_netplay_class = reinterpret_cast<jclass>(env->NewGlobalRef(netplay_class));
|
||||
s_net_play_ui_callbacks_pointer =
|
||||
env->GetFieldID(netplay_class, "netPlayUICallbacksPointer", "J");
|
||||
s_net_play_client_pointer = env->GetFieldID(netplay_class, "netPlayClientPointer", "J");
|
||||
s_net_play_server_pointer = env->GetFieldID(netplay_class, "netPlayServerPointer", "J");
|
||||
s_netplay_on_boot_game = env->GetMethodID(netplay_class, "onBootGame", "(Ljava/lang/String;J)V");
|
||||
s_netplay_on_stop_game = env->GetMethodID(netplay_class, "onStopGame", "()V");
|
||||
s_netplay_on_connection_lost = env->GetMethodID(netplay_class, "onConnectionLost", "()V");
|
||||
s_netplay_on_connection_error = env->GetMethodID(netplay_class, "onConnectionError", "(Ljava/lang/String;)V");
|
||||
s_netplay_on_game_changed =
|
||||
env->GetMethodID(netplay_class, "onGameChanged", "(Ljava/lang/String;)V");
|
||||
s_netplay_on_host_input_authority_changed =
|
||||
env->GetMethodID(netplay_class, "onHostInputAuthorityChanged", "(Z)V");
|
||||
s_netplay_on_pad_buffer_changed =
|
||||
env->GetMethodID(netplay_class, "onPadBufferChanged", "(I)V");
|
||||
s_netplay_on_chat_message_received =
|
||||
env->GetMethodID(netplay_class, "onChatMessageReceived", "(Ljava/lang/String;)V");
|
||||
s_netplay_update = env->GetMethodID(netplay_class, "onUpdate", "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V");
|
||||
s_netplay_on_show_chunked_progress_dialog =
|
||||
env->GetMethodID(netplay_class, "onShowChunkedProgressDialog", "(Ljava/lang/String;J[I)V");
|
||||
s_netplay_on_set_chunked_progress =
|
||||
env->GetMethodID(netplay_class, "onSetChunkedProgress", "(IJ)V");
|
||||
s_netplay_on_hide_chunked_progress_dialog =
|
||||
env->GetMethodID(netplay_class, "onHideChunkedProgressDialog", "()V");
|
||||
s_netplay_on_desync =
|
||||
env->GetMethodID(netplay_class, "onDesync", "(ILjava/lang/String;)V");
|
||||
s_netplay_on_show_game_digest_dialog =
|
||||
env->GetMethodID(netplay_class, "onShowGameDigestDialog", "(Ljava/lang/String;)V");
|
||||
s_netplay_on_set_game_digest_progress =
|
||||
env->GetMethodID(netplay_class, "onSetGameDigestProgress", "(II)V");
|
||||
s_netplay_on_set_game_digest_result =
|
||||
env->GetMethodID(netplay_class, "onSetGameDigestResult", "(ILjava/lang/String;)V");
|
||||
s_netplay_on_abort_game_digest =
|
||||
env->GetMethodID(netplay_class, "onAbortGameDigest", "()V");
|
||||
s_netplay_on_traversal_state_changed = env->GetMethodID(
|
||||
netplay_class, "onTraversalStateChanged",
|
||||
"(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V");
|
||||
env->DeleteLocalRef(netplay_class);
|
||||
|
||||
const jclass netplay_player_class =
|
||||
env->FindClass("org/dolphinemu/dolphinemu/features/netplay/model/Player");
|
||||
s_netplay_player_class = reinterpret_cast<jclass>(env->NewGlobalRef(netplay_player_class));
|
||||
s_netplay_player_constructor = env->GetMethodID(netplay_player_class, "<init>", "(ILjava/lang/String;Ljava/lang/String;IZLjava/lang/String;)V");
|
||||
env->DeleteLocalRef(netplay_player_class);
|
||||
|
||||
const jclass analytics_class = env->FindClass("org/dolphinemu/dolphinemu/utils/Analytics");
|
||||
s_analytics_class = reinterpret_cast<jclass>(env->NewGlobalRef(analytics_class));
|
||||
s_get_analytics_value = env->GetStaticMethodID(s_analytics_class, "getValue",
|
||||
|
|
@ -828,6 +1043,9 @@ JNIEXPORT void JNI_OnUnload(JavaVM* vm, void* reserved)
|
|||
env->DeleteGlobalRef(s_native_library_class);
|
||||
env->DeleteGlobalRef(s_game_file_class);
|
||||
env->DeleteGlobalRef(s_game_file_cache_class);
|
||||
env->DeleteGlobalRef(s_game_file_cache_manager_class);
|
||||
env->DeleteGlobalRef(s_netplay_class);
|
||||
env->DeleteGlobalRef(s_netplay_player_class);
|
||||
env->DeleteGlobalRef(s_analytics_class);
|
||||
env->DeleteGlobalRef(s_pair_class);
|
||||
env->DeleteGlobalRef(s_hash_map_class);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,35 @@ jmethodID GetGameFileConstructor();
|
|||
jclass GetGameFileCacheClass();
|
||||
jfieldID GetGameFileCachePointer();
|
||||
|
||||
jclass GetGameFileCacheManagerClass();
|
||||
jfieldID GetGameFileCacheManagerInstance();
|
||||
|
||||
jclass GetNetplayClass();
|
||||
jfieldID GetNetPlayUICallbacksPointer();
|
||||
jfieldID GetNetPlayClientPointer();
|
||||
jfieldID GetNetPlayServerPointer();
|
||||
jmethodID GetNetplayOnBootGame();
|
||||
jmethodID GetNetplayOnStopGame();
|
||||
jmethodID GetNetplayOnConnectionLost();
|
||||
jmethodID GetNetplayOnConnectionError();
|
||||
jmethodID GetNetplayOnGameChanged();
|
||||
jmethodID GetNetplayOnHostInputAuthorityChanged();
|
||||
jmethodID GetNetplayOnPadBufferChanged();
|
||||
jmethodID GetNetplayOnChatMessageReceived();
|
||||
jmethodID GetNetplayUpdate();
|
||||
jmethodID GetNetplayOnShowChunkedProgressDialog();
|
||||
jmethodID GetNetplayOnSetChunkedProgress();
|
||||
jmethodID GetNetplayOnHideChunkedProgressDialog();
|
||||
jmethodID GetNetplayOnDesync();
|
||||
jmethodID GetNetplayOnShowGameDigestDialog();
|
||||
jmethodID GetNetplayOnSetGameDigestProgress();
|
||||
jmethodID GetNetplayOnSetGameDigestResult();
|
||||
jmethodID GetNetplayOnAbortGameDigest();
|
||||
jmethodID GetNetplayOnTraversalStateChanged();
|
||||
|
||||
jclass GetNetplayPlayerClass();
|
||||
jmethodID GetNetplayPlayerConstructor();
|
||||
|
||||
jclass GetPairClass();
|
||||
jmethodID GetPairConstructor();
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ add_library(main SHARED
|
|||
Input/MappingCommon.cpp
|
||||
Input/NumericSetting.cpp
|
||||
Input/NumericSetting.h
|
||||
NetPlay/Netplay.cpp
|
||||
NetPlay/NetPlayUICallbacks.cpp
|
||||
NetPlay/NetPlayUICallbacks.h
|
||||
MainAndroid.cpp
|
||||
RiivolutionPatches.cpp
|
||||
SkylanderConfig.cpp
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
|
||||
#include "Core/AchievementManager.h"
|
||||
#include "Core/Boot/Boot.h"
|
||||
#include "jni/NetPlay/NetPlayUICallbacks.h"
|
||||
#include "Core/BootManager.h"
|
||||
#include "Core/CommonTitles.h"
|
||||
#include "Core/ConfigLoaders/GameConfigLoader.h"
|
||||
|
|
@ -614,6 +615,21 @@ Java_org_dolphinemu_dolphinemu_NativeLibrary_Run___3Ljava_lang_String_2ZLjava_la
|
|||
BootSessionData(GetJString(env, jSavestate), delete_state));
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_NativeLibrary_RunNetPlay(
|
||||
JNIEnv* env, jclass, jobjectArray jPaths, jboolean jRiivolution, jlong jBootSessionData)
|
||||
{
|
||||
auto boot_session_data = std::unique_ptr<BootSessionData>(
|
||||
reinterpret_cast<BootSessionData*>(jBootSessionData));
|
||||
if (!boot_session_data)
|
||||
{
|
||||
env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), IDCache::GetDisplayToastMsg(),
|
||||
ToJString(env, "Netplay: no boot session data"), JNI_TRUE);
|
||||
env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), IDCache::GetFinishEmulationActivity());
|
||||
return;
|
||||
}
|
||||
Run(env, JStringArrayToVector(env, jPaths), jRiivolution, std::move(*boot_session_data));
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_NativeLibrary_RunSystemMenu(JNIEnv* env,
|
||||
jclass)
|
||||
{
|
||||
|
|
|
|||
455
Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp
Normal file
455
Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
// Copyright 2003 Dolphin Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <android/log.h>
|
||||
|
||||
#include "UICommon/GameFile.h"
|
||||
#include "UICommon/UICommon.h"
|
||||
#include "NetPlayUICallbacks.h"
|
||||
#include "Common/TraversalClient.h"
|
||||
#include "Core/Boot/Boot.h"
|
||||
#include "Core/Core.h"
|
||||
#include "Core/System.h"
|
||||
#include "jni/AndroidCommon/AndroidCommon.h"
|
||||
#include "jni/AndroidCommon/IDCache.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
std::string InetAddressToString(const Common::TraversalInetAddress& addr)
|
||||
{
|
||||
std::string ip;
|
||||
|
||||
if (addr.isIPV6)
|
||||
{
|
||||
ip = "IPv6-Not-Implemented";
|
||||
}
|
||||
else
|
||||
{
|
||||
const auto ipv4 = reinterpret_cast<const u8*>(addr.address);
|
||||
ip = std::to_string(ipv4[0]);
|
||||
for (u32 i = 1; i != 4; ++i)
|
||||
{
|
||||
ip += ".";
|
||||
ip += std::to_string(ipv4[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return ip + ":" + std::to_string(ntohs(addr.port));
|
||||
}
|
||||
|
||||
const char* FailureReasonToString(Common::TraversalClient::FailureReason reason)
|
||||
{
|
||||
switch (reason)
|
||||
{
|
||||
case Common::TraversalClient::FailureReason::BadHost:
|
||||
return "BadHost";
|
||||
case Common::TraversalClient::FailureReason::VersionTooOld:
|
||||
return "VersionTooOld";
|
||||
case Common::TraversalClient::FailureReason::ServerForgotAboutUs:
|
||||
return "ServerForgotAboutUs";
|
||||
case Common::TraversalClient::FailureReason::SocketSendError:
|
||||
return "SocketSendError";
|
||||
case Common::TraversalClient::FailureReason::ResendTimeout:
|
||||
return "ResendTimeout";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace NetPlay {
|
||||
|
||||
NetPlayUICallbacks::NetPlayUICallbacks(jobject netplay_session,
|
||||
std::vector<std::shared_ptr<const UICommon::GameFile>> games)
|
||||
: m_netplay_session(IDCache::GetEnvForThread()->NewWeakGlobalRef(netplay_session)),
|
||||
m_games(std::move(games))
|
||||
{
|
||||
m_state_changed_hook = Core::AddOnStateChangedCallback([this](Core::State state) {
|
||||
if ((state == Core::State::Uninitialized || state == Core::State::Stopping) &&
|
||||
!m_got_stop_request)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
auto* client = reinterpret_cast<NetPlay::NetPlayClient*>(
|
||||
env->GetLongField(netplay_session, IDCache::GetNetPlayClientPointer()));
|
||||
if (client)
|
||||
client->RequestStopGame();
|
||||
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
NetPlayUICallbacks::~NetPlayUICallbacks()
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
env->DeleteWeakGlobalRef(m_netplay_session);
|
||||
}
|
||||
|
||||
jobject NetPlayUICallbacks::GetNetplaySessionLocalRef(JNIEnv* env) const
|
||||
{
|
||||
return env->NewLocalRef(m_netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::BootGame(const std::string& filename,
|
||||
std::unique_ptr<BootSessionData> boot_session_data)
|
||||
{
|
||||
m_got_stop_request = false;
|
||||
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnBootGame(), ToJString(env, filename),
|
||||
reinterpret_cast<jlong>(boot_session_data.release()));
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::StopGame()
|
||||
{
|
||||
if (m_got_stop_request)
|
||||
return;
|
||||
|
||||
m_got_stop_request = true;
|
||||
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnStopGame());
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
// Only used by Qt UI code, never by the C++ core. On Android, hosting state
|
||||
// is tracked in Kotlin (NetplaySession.isHosting).
|
||||
bool NetPlayUICallbacks::IsHosting() const { return false; }
|
||||
|
||||
void NetPlayUICallbacks::Update()
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
auto* client = reinterpret_cast<NetPlay::NetPlayClient*>(
|
||||
env->GetLongField(netplay_session, IDCache::GetNetPlayClientPointer()));
|
||||
if (!client)
|
||||
{
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
return;
|
||||
}
|
||||
|
||||
const std::vector<const NetPlay::Player*> players = client->GetPlayers();
|
||||
|
||||
jobjectArray player_array =
|
||||
env->NewObjectArray(static_cast<jsize>(players.size()), IDCache::GetNetplayPlayerClass(), nullptr);
|
||||
|
||||
for (jsize i = 0; i < static_cast<jsize>(players.size()); i++)
|
||||
{
|
||||
const NetPlay::Player* player = players[i];
|
||||
const std::string mapping = NetPlay::GetPlayerMappingString(
|
||||
player->pid, client->GetPadMapping(), client->GetGBAConfig(), client->GetWiimoteMapping());
|
||||
jobject player_obj = env->NewObject(
|
||||
IDCache::GetNetplayPlayerClass(), IDCache::GetNetplayPlayerConstructor(),
|
||||
static_cast<jint>(player->pid),
|
||||
ToJString(env, player->name),
|
||||
ToJString(env, player->revision),
|
||||
static_cast<jint>(player->ping),
|
||||
static_cast<jboolean>(player->IsHost()),
|
||||
ToJString(env, mapping));
|
||||
env->SetObjectArrayElement(player_array, i, player_obj);
|
||||
env->DeleteLocalRef(player_obj);
|
||||
}
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayUpdate(), player_array);
|
||||
env->DeleteLocalRef(player_array);
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::AppendChat(const std::string& message)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnChatMessageReceived(),
|
||||
ToJString(env, message));
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_identifier,
|
||||
const std::string& netplay_name)
|
||||
{
|
||||
m_current_game_identifier = sync_identifier;
|
||||
m_current_game_name = netplay_name;
|
||||
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnGameChanged(),
|
||||
ToJString(env, netplay_name));
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::OnMsgChangeGBARom(int, const NetPlay::GBAConfig&) {}
|
||||
|
||||
void NetPlayUICallbacks::OnMsgStartGame()
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
auto* client = reinterpret_cast<NetPlay::NetPlayClient*>(
|
||||
env->GetLongField(netplay_session, IDCache::GetNetPlayClientPointer()));
|
||||
if (client)
|
||||
{
|
||||
if (const auto game = FindGameFile(m_current_game_identifier))
|
||||
client->StartGame(game->GetFilePath());
|
||||
}
|
||||
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::OnMsgStopGame() {}
|
||||
|
||||
void NetPlayUICallbacks::OnMsgPowerButton()
|
||||
{
|
||||
if (Core::IsRunning(Core::System::GetInstance()))
|
||||
UICommon::TriggerSTMPowerEvent();
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::OnPlayerConnect(const std::string&) {}
|
||||
void NetPlayUICallbacks::OnPlayerDisconnect(const std::string&) {}
|
||||
|
||||
void NetPlayUICallbacks::OnPadBufferChanged(u32 buffer)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnPadBufferChanged(),
|
||||
static_cast<jint>(buffer));
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::OnHostInputAuthorityChanged(bool enabled)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnHostInputAuthorityChanged(),
|
||||
static_cast<jboolean>(enabled));
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::OnDesync(u32 frame, const std::string& player)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnDesync(),
|
||||
static_cast<jint>(frame), ToJString(env, player));
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::OnConnectionLost()
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnConnectionLost());
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::OnConnectionError(const std::string& message)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnConnectionError(),
|
||||
ToJString(env, message));
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
// No-op — all error info is captured by OnTraversalStateChanged which always fires alongside.
|
||||
void NetPlayUICallbacks::OnTraversalError(Common::TraversalClient::FailureReason) {}
|
||||
|
||||
void NetPlayUICallbacks::OnTraversalStateChanged(Common::TraversalClient::State state)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
jstring host_code = nullptr;
|
||||
jstring external_address = nullptr;
|
||||
jstring failure_reason = nullptr;
|
||||
|
||||
if (Common::g_TraversalClient)
|
||||
{
|
||||
if (state == Common::TraversalClient::State::Connected)
|
||||
{
|
||||
const auto host_id = Common::g_TraversalClient->GetHostID();
|
||||
host_code = ToJString(env, std::string(host_id.begin(), host_id.end()));
|
||||
external_address =
|
||||
ToJString(env, InetAddressToString(Common::g_TraversalClient->GetExternalAddress()));
|
||||
}
|
||||
else if (state == Common::TraversalClient::State::Failure)
|
||||
{
|
||||
failure_reason =
|
||||
ToJString(env, FailureReasonToString(Common::g_TraversalClient->GetFailureReason()));
|
||||
}
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnTraversalStateChanged(),
|
||||
static_cast<jint>(state), host_code, external_address, failure_reason);
|
||||
}
|
||||
|
||||
if (host_code)
|
||||
env->DeleteLocalRef(host_code);
|
||||
if (external_address)
|
||||
env->DeleteLocalRef(external_address);
|
||||
if (failure_reason)
|
||||
env->DeleteLocalRef(failure_reason);
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::OnGameStartAborted() {}
|
||||
void NetPlayUICallbacks::OnGolferChanged(bool, const std::string&) {}
|
||||
void NetPlayUICallbacks::OnTtlDetermined(u8) {}
|
||||
void NetPlayUICallbacks::OnIndexAdded(bool, std::string) {}
|
||||
void NetPlayUICallbacks::OnIndexRefreshFailed(std::string) {}
|
||||
bool NetPlayUICallbacks::IsRecording() { return false; }
|
||||
|
||||
std::shared_ptr<const UICommon::GameFile>
|
||||
NetPlayUICallbacks::FindGameFile(const NetPlay::SyncIdentifier& sync_identifier,
|
||||
NetPlay::SyncIdentifierComparison* found)
|
||||
{
|
||||
NetPlay::SyncIdentifierComparison temp;
|
||||
if (!found)
|
||||
found = &temp;
|
||||
|
||||
*found = NetPlay::SyncIdentifierComparison::DifferentGame;
|
||||
|
||||
std::shared_ptr<const UICommon::GameFile> result;
|
||||
for (const auto& game : m_games)
|
||||
{
|
||||
const auto cmp = game->CompareSyncIdentifier(sync_identifier);
|
||||
if (cmp < *found)
|
||||
{
|
||||
*found = cmp;
|
||||
result = game;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string NetPlayUICallbacks::FindGBARomPath(const std::array<u8, 20>&, std::string_view, int) { return {}; }
|
||||
|
||||
void NetPlayUICallbacks::ShowGameDigestDialog(const std::string& title)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnShowGameDigestDialog(),
|
||||
ToJString(env, title));
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::SetGameDigestProgress(int pid, int progress)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnSetGameDigestProgress(),
|
||||
static_cast<jint>(pid), static_cast<jint>(progress));
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::SetGameDigestResult(int pid, const std::string& result)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnSetGameDigestResult(),
|
||||
static_cast<jint>(pid), ToJString(env, result));
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::AbortGameDigest()
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnAbortGameDigest());
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string& title, u64 data_size,
|
||||
std::span<const int> players)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
jintArray j_players = env->NewIntArray(static_cast<jsize>(players.size()));
|
||||
env->SetIntArrayRegion(j_players, 0, static_cast<jsize>(players.size()), players.data());
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnShowChunkedProgressDialog(),
|
||||
ToJString(env, title), static_cast<jlong>(data_size), j_players);
|
||||
env->DeleteLocalRef(j_players);
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::HideChunkedProgressDialog()
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnHideChunkedProgressDialog());
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::SetChunkedProgress(int pid, u64 progress)
|
||||
{
|
||||
JNIEnv* env = IDCache::GetEnvForThread();
|
||||
jobject netplay_session = GetNetplaySessionLocalRef(env);
|
||||
if (!netplay_session)
|
||||
return;
|
||||
|
||||
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnSetChunkedProgress(),
|
||||
static_cast<jint>(pid), static_cast<jlong>(progress));
|
||||
env->DeleteLocalRef(netplay_session);
|
||||
}
|
||||
|
||||
void NetPlayUICallbacks::SetHostWiiSyncData(std::vector<u64>, std::string) {}
|
||||
|
||||
} // namespace NetPlay
|
||||
75
Source/Android/jni/NetPlay/NetPlayUICallbacks.h
Normal file
75
Source/Android/jni/NetPlay/NetPlayUICallbacks.h
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include "Common/HookableEvent.h"
|
||||
#include "Core/NetPlayClient.h"
|
||||
#include "UICommon/GameFile.h"
|
||||
|
||||
namespace NetPlay {
|
||||
|
||||
class NetPlayUICallbacks : public NetPlay::NetPlayUI {
|
||||
public:
|
||||
NetPlayUICallbacks(jobject netplay_session,
|
||||
std::vector<std::shared_ptr<const UICommon::GameFile>> games);
|
||||
~NetPlayUICallbacks() override;
|
||||
|
||||
void BootGame(const std::string& filename,
|
||||
std::unique_ptr<BootSessionData> boot_session_data) override;
|
||||
void StopGame() override;
|
||||
bool IsHosting() const override;
|
||||
void Update() override;
|
||||
void AppendChat(const std::string& msg) override;
|
||||
void OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_identifier,
|
||||
const std::string& netplay_name) override;
|
||||
void OnMsgChangeGBARom(int pad, const NetPlay::GBAConfig& config) override;
|
||||
void OnMsgStartGame() override;
|
||||
void OnMsgStopGame() override;
|
||||
void OnMsgPowerButton() override;
|
||||
void OnPlayerConnect(const std::string& player) override;
|
||||
void OnPlayerDisconnect(const std::string& player) override;
|
||||
void OnPadBufferChanged(u32 buffer) override;
|
||||
void OnHostInputAuthorityChanged(bool enabled) override;
|
||||
void OnDesync(u32 frame, const std::string& player) override;
|
||||
void OnConnectionLost() override;
|
||||
void OnConnectionError(const std::string& message) override;
|
||||
void OnTraversalError(Common::TraversalClient::FailureReason error) override;
|
||||
void OnTraversalStateChanged(Common::TraversalClient::State state) override;
|
||||
void OnGameStartAborted() override;
|
||||
void OnGolferChanged(bool is_golfer, const std::string& golfer_name) override;
|
||||
void OnTtlDetermined(u8 ttl) override;
|
||||
void OnIndexAdded(bool success, std::string error) override;
|
||||
void OnIndexRefreshFailed(std::string error) override;
|
||||
bool IsRecording() override;
|
||||
std::shared_ptr<const UICommon::GameFile>
|
||||
FindGameFile(const NetPlay::SyncIdentifier& sync_identifier,
|
||||
NetPlay::SyncIdentifierComparison* found = nullptr) override;
|
||||
std::string FindGBARomPath(const std::array<u8, 20>& hash, std::string_view title,
|
||||
int device_number) override;
|
||||
void ShowGameDigestDialog(const std::string& title) override;
|
||||
void SetGameDigestProgress(int pid, int progress) override;
|
||||
void SetGameDigestResult(int pid, const std::string& result) override;
|
||||
void AbortGameDigest() override;
|
||||
void ShowChunkedProgressDialog(const std::string& title, u64 data_size,
|
||||
std::span<const int> players) override;
|
||||
void HideChunkedProgressDialog() override;
|
||||
void SetChunkedProgress(int pid, u64 progress) override;
|
||||
void SetHostWiiSyncData(std::vector<u64> titles, std::string redirect_folder) override;
|
||||
|
||||
private:
|
||||
jobject GetNetplaySessionLocalRef(JNIEnv* env) const;
|
||||
|
||||
jweak m_netplay_session;
|
||||
std::vector<std::shared_ptr<const UICommon::GameFile>> m_games;
|
||||
NetPlay::SyncIdentifier m_current_game_identifier;
|
||||
std::string m_current_game_name;
|
||||
Common::EventHook m_state_changed_hook;
|
||||
bool m_got_stop_request = true;
|
||||
};
|
||||
|
||||
} // namespace NetPlay
|
||||
261
Source/Android/jni/NetPlay/Netplay.cpp
Normal file
261
Source/Android/jni/NetPlay/Netplay.cpp
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// Copyright 2003 Dolphin Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include "Common/CommonTypes.h"
|
||||
#include "Common/TraversalClient.h"
|
||||
#include "Core/NetPlayCommon.h"
|
||||
#include "Core/Boot/Boot.h"
|
||||
#include "Core/Config/NetplaySettings.h"
|
||||
#include "Core/NetPlayClient.h"
|
||||
#include "Core/NetPlayServer.h"
|
||||
#include "UICommon/GameFile.h"
|
||||
#include "UICommon/GameFileCache.h"
|
||||
|
||||
#include "jni/AndroidCommon/AndroidCommon.h"
|
||||
#include "jni/AndroidCommon/IDCache.h"
|
||||
#include "jni/NetPlay/NetPlayUICallbacks.h"
|
||||
|
||||
static NetPlay::NetPlayUICallbacks* GetUICallbacksPointer(JNIEnv* env, jobject obj)
|
||||
{
|
||||
return reinterpret_cast<NetPlay::NetPlayUICallbacks*>(
|
||||
env->GetLongField(obj, IDCache::GetNetPlayUICallbacksPointer()));
|
||||
}
|
||||
|
||||
static NetPlay::NetPlayClient* GetClientPointer(JNIEnv* env, jobject obj)
|
||||
{
|
||||
return reinterpret_cast<NetPlay::NetPlayClient*>(
|
||||
env->GetLongField(obj, IDCache::GetNetPlayClientPointer()));
|
||||
}
|
||||
|
||||
static NetPlay::NetPlayServer* GetServerPointer(JNIEnv* env, jobject obj)
|
||||
{
|
||||
return reinterpret_cast<NetPlay::NetPlayServer*>(
|
||||
env->GetLongField(obj, IDCache::GetNetPlayServerPointer()));
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeSendMessage(JNIEnv* env, jobject obj,
|
||||
jstring jmessage)
|
||||
{
|
||||
if (auto* client = GetClientPointer(env, obj))
|
||||
client->SendChatMessage(GetJString(env, jmessage));
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeSetHostInputAuthority(
|
||||
JNIEnv* env, jobject obj, jboolean enable)
|
||||
{
|
||||
if (auto* server = GetServerPointer(env, obj))
|
||||
server->SetHostInputAuthority(static_cast<bool>(enable));
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeAdjustClientPadBufferSize(JNIEnv* env,
|
||||
jobject obj,
|
||||
jint buffer)
|
||||
{
|
||||
if (auto* client = GetClientPointer(env, obj))
|
||||
client->AdjustPadBufferSize(static_cast<u32>(buffer));
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeAdjustServerPadBufferSize(
|
||||
JNIEnv* env, jobject obj, jint buffer)
|
||||
{
|
||||
if (auto* server = GetServerPointer(env, obj))
|
||||
server->AdjustPadBufferSize(static_cast<u32>(buffer));
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeCreateUICallbacks(JNIEnv* env,
|
||||
jobject obj)
|
||||
{
|
||||
jobject jgame_file_cache = env->GetStaticObjectField(
|
||||
IDCache::GetGameFileCacheManagerClass(), IDCache::GetGameFileCacheManagerInstance());
|
||||
auto* game_file_cache = reinterpret_cast<UICommon::GameFileCache*>(
|
||||
env->GetLongField(jgame_file_cache, IDCache::GetGameFileCachePointer()));
|
||||
|
||||
std::vector<std::shared_ptr<const UICommon::GameFile>> games;
|
||||
game_file_cache->ForEach(
|
||||
[&games](const std::shared_ptr<const UICommon::GameFile>& game) { games.push_back(game); });
|
||||
|
||||
return reinterpret_cast<jlong>(new NetPlay::NetPlayUICallbacks(obj, std::move(games)));
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeJoin(JNIEnv* env, jobject obj)
|
||||
{
|
||||
auto* ui = GetUICallbacksPointer(env, obj);
|
||||
|
||||
const std::string traversal_host = Config::Get(Config::NETPLAY_TRAVERSAL_SERVER);
|
||||
const u16 traversal_port = Config::Get(Config::NETPLAY_TRAVERSAL_PORT);
|
||||
const std::string nickname = Config::Get(Config::NETPLAY_NICKNAME);
|
||||
|
||||
std::string host_ip;
|
||||
u16 host_port;
|
||||
bool is_traversal;
|
||||
|
||||
// When hosting, join our own server on localhost
|
||||
if (auto* server = GetServerPointer(env, obj))
|
||||
{
|
||||
host_ip = "127.0.0.1";
|
||||
host_port = server->GetPort();
|
||||
is_traversal = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
const std::string traversal_choice = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE);
|
||||
is_traversal = traversal_choice == "traversal";
|
||||
host_ip = is_traversal ? Config::Get(Config::NETPLAY_HOST_CODE) :
|
||||
Config::Get(Config::NETPLAY_ADDRESS);
|
||||
host_port = Config::Get(Config::NETPLAY_CONNECT_PORT);
|
||||
}
|
||||
|
||||
auto* client = new NetPlay::NetPlayClient(
|
||||
host_ip, host_port, ui, nickname,
|
||||
NetPlay::NetTraversalConfig{is_traversal, traversal_host, traversal_port});
|
||||
|
||||
if (!client->IsConnected())
|
||||
{
|
||||
delete client;
|
||||
return 0;
|
||||
}
|
||||
|
||||
return reinterpret_cast<jlong>(client);
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeHost(JNIEnv* env, jobject obj)
|
||||
{
|
||||
auto* ui = GetUICallbacksPointer(env, obj);
|
||||
|
||||
const std::string traversal_choice = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE);
|
||||
const bool is_traversal = traversal_choice == "traversal";
|
||||
const bool use_upnp = Config::Get(Config::NETPLAY_USE_UPNP);
|
||||
const std::string traversal_host = Config::Get(Config::NETPLAY_TRAVERSAL_SERVER);
|
||||
const u16 traversal_port = Config::Get(Config::NETPLAY_TRAVERSAL_PORT);
|
||||
const u16 traversal_port_alt = Config::Get(Config::NETPLAY_TRAVERSAL_PORT_ALT);
|
||||
|
||||
const u16 host_port = is_traversal ? Config::Get(Config::NETPLAY_LISTEN_PORT)
|
||||
: Config::Get(Config::NETPLAY_HOST_PORT);
|
||||
|
||||
auto* server = new NetPlay::NetPlayServer(
|
||||
host_port, use_upnp, ui,
|
||||
NetPlay::NetTraversalConfig{is_traversal, traversal_host, traversal_port, traversal_port_alt});
|
||||
|
||||
if (!server->is_connected)
|
||||
{
|
||||
delete server;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const std::string network_mode = Config::Get(Config::NETPLAY_NETWORK_MODE);
|
||||
const bool host_input_authority =
|
||||
network_mode == "hostinputauthority" || network_mode == "golf";
|
||||
server->SetHostInputAuthority(host_input_authority);
|
||||
server->AdjustPadBufferSize(Config::Get(Config::NETPLAY_BUFFER_SIZE));
|
||||
|
||||
return reinterpret_cast<jlong>(server);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeChangeGame(JNIEnv* env,
|
||||
jobject obj,
|
||||
jobject jgame_file)
|
||||
{
|
||||
auto* server = GetServerPointer(env, obj);
|
||||
if (!server)
|
||||
return;
|
||||
|
||||
const auto& game_file = *reinterpret_cast<std::shared_ptr<const UICommon::GameFile>*>(
|
||||
env->GetLongField(jgame_file, IDCache::GetGameFilePointer()));
|
||||
|
||||
server->ChangeGame(game_file->GetSyncIdentifier(), game_file->GetLongName());
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeDoAllPlayersHaveGame(
|
||||
JNIEnv* env, jobject obj)
|
||||
{
|
||||
if (auto* client = GetClientPointer(env, obj))
|
||||
return static_cast<jboolean>(client->DoAllPlayersHaveGame());
|
||||
return JNI_TRUE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeStartGame(JNIEnv* env,
|
||||
jobject obj)
|
||||
{
|
||||
auto* server = GetServerPointer(env, obj);
|
||||
if (!server)
|
||||
return;
|
||||
|
||||
server->RequestStartGame();
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeGetPort(JNIEnv* env,
|
||||
jobject obj)
|
||||
{
|
||||
if (auto* server = GetServerPointer(env, obj))
|
||||
return static_cast<jint>(server->GetPort());
|
||||
return 0;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeGetExternalIpAddress(
|
||||
JNIEnv* env, jobject)
|
||||
{
|
||||
std::string ip = NetPlay::GetExternalIPAddress();
|
||||
if (ip.empty())
|
||||
return nullptr;
|
||||
return ToJString(env, ip);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReconnectTraversal(JNIEnv*,
|
||||
jobject)
|
||||
{
|
||||
if (Common::g_TraversalClient)
|
||||
Common::g_TraversalClient->ReconnectToServer();
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseUICallbacks(JNIEnv*,
|
||||
jobject,
|
||||
jlong pointer)
|
||||
{
|
||||
delete reinterpret_cast<NetPlay::NetPlayUICallbacks*>(pointer);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseBootSessionData(JNIEnv*,
|
||||
jobject,
|
||||
jlong pointer)
|
||||
{
|
||||
delete reinterpret_cast<BootSessionData*>(pointer);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseClient(JNIEnv*, jobject,
|
||||
jlong pointer)
|
||||
{
|
||||
delete reinterpret_cast<NetPlay::NetPlayClient*>(pointer);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseServer(JNIEnv*, jobject,
|
||||
jlong pointer)
|
||||
{
|
||||
delete reinterpret_cast<NetPlay::NetPlayServer*>(pointer);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
#include <lzo/lzo1x.h>
|
||||
|
||||
#include "Common/FileUtil.h"
|
||||
#include "Common/HttpRequest.h"
|
||||
#include "Common/IOFile.h"
|
||||
#include "Common/MsgHandler.h"
|
||||
#include "Common/SFMLHelper.h"
|
||||
|
|
@ -297,4 +298,18 @@ std::optional<std::vector<u8>> DecompressPacketIntoBuffer(sf::Packet& packet)
|
|||
|
||||
return out_buffer;
|
||||
}
|
||||
|
||||
std::string GetExternalIPAddress()
|
||||
{
|
||||
Common::HttpRequest request;
|
||||
// ENet does not support IPv6, so IPv4 has to be used
|
||||
request.UseIPv4();
|
||||
Common::HttpRequest::Response response =
|
||||
request.Get("https://ip.dolphin-emu.org/", {{"X-Is-Dolphin", "1"}});
|
||||
|
||||
if (response.has_value())
|
||||
return std::string(response->begin(), response->end());
|
||||
return "";
|
||||
}
|
||||
|
||||
} // namespace NetPlay
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ using namespace std::chrono_literals;
|
|||
// connection is disconnected
|
||||
constexpr std::chrono::milliseconds PEER_TIMEOUT = 30s;
|
||||
|
||||
std::string GetExternalIPAddress();
|
||||
|
||||
bool CompressFileIntoPacket(const std::string& file_path, sf::Packet& packet);
|
||||
bool CompressFolderIntoPacket(const std::string& folder_path, sf::Packet& packet);
|
||||
bool CompressBufferIntoPacket(std::span<const u8> in_buffer, sf::Packet& packet);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@
|
|||
#include "Common/CommonPaths.h"
|
||||
#include "Common/ENet.h"
|
||||
#include "Common/FileUtil.h"
|
||||
#include "Common/HttpRequest.h"
|
||||
#include "Common/Logging/Log.h"
|
||||
#include "Common/MsgHandler.h"
|
||||
#include "Common/SFMLHelper.h"
|
||||
|
|
@ -220,16 +219,9 @@ void NetPlayServer::SetupIndex()
|
|||
}
|
||||
else
|
||||
{
|
||||
Common::HttpRequest request;
|
||||
// ENet does not support IPv6, so IPv4 has to be used
|
||||
request.UseIPv4();
|
||||
Common::HttpRequest::Response response =
|
||||
request.Get("https://ip.dolphin-emu.org/", {{"X-Is-Dolphin", "1"}});
|
||||
|
||||
if (!response.has_value())
|
||||
session.server_id = GetExternalIPAddress();
|
||||
if (session.server_id.empty())
|
||||
return;
|
||||
|
||||
session.server_id = std::string(response->begin(), response->end());
|
||||
}
|
||||
|
||||
session.EncryptID(Config::Get(Config::NETPLAY_INDEX_PASSWORD));
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
#endif
|
||||
|
||||
#include "Common/Config/Config.h"
|
||||
#include "Common/HttpRequest.h"
|
||||
#include "Core/NetPlayCommon.h"
|
||||
#include "Common/Logging/Log.h"
|
||||
#include "Common/TraversalClient.h"
|
||||
|
||||
|
|
@ -550,17 +550,8 @@ void NetPlayDialog::show(std::string nickname, bool use_traversal)
|
|||
|
||||
void NetPlayDialog::ResetExternalIP()
|
||||
{
|
||||
m_external_ip_address = Common::Lazy<std::string>([]() -> std::string {
|
||||
Common::HttpRequest request;
|
||||
// ENet does not support IPv6, so IPv4 has to be used
|
||||
request.UseIPv4();
|
||||
Common::HttpRequest::Response response =
|
||||
request.Get("https://ip.dolphin-emu.org/", {{"X-Is-Dolphin", "1"}});
|
||||
|
||||
if (response.has_value())
|
||||
return std::string(response->begin(), response->end());
|
||||
return "";
|
||||
});
|
||||
m_external_ip_address =
|
||||
Common::Lazy<std::string>([]() -> std::string { return NetPlay::GetExternalIPAddress(); });
|
||||
}
|
||||
|
||||
void NetPlayDialog::UpdateDiscordPresence()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user