mirror of
https://github.com/dolphin-emu/dolphin.git
synced 2026-10-01 22:57:00 -05:00
@@ -1,5 +1,6 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.androidx.baselineprofile)
|
||||
}
|
||||
@@ -10,6 +11,7 @@ android {
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
resValues = true
|
||||
@@ -116,6 +118,12 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xannotation-default-target=param-property")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
baselineProfile(project(":benchmark"))
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
@@ -133,6 +141,7 @@ dependencies {
|
||||
implementation(libs.androidx.profileinstaller)
|
||||
|
||||
// Kotlin extensions for lifecycle components
|
||||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
|
||||
@@ -143,6 +152,7 @@ dependencies {
|
||||
|
||||
// For loading game covers from disk and GameTDB
|
||||
implementation(libs.coil)
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// For loading custom GPU drivers
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
@@ -150,6 +160,17 @@ dependencies {
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
implementation(libs.filepicker)
|
||||
|
||||
// Jetpack Compose
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.material.icons)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material3.adaptive)
|
||||
implementation(libs.androidx.compose.runtime.livedata)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling.preview)
|
||||
}
|
||||
|
||||
fun getGitVersion(): String {
|
||||
|
||||
@@ -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,37 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
object NetplayManager {
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var closeComplete: CompletableDeferred<Unit>? = null
|
||||
|
||||
@Volatile
|
||||
var activeSession: NetplaySession? = null
|
||||
private set
|
||||
|
||||
suspend fun createSession(): NetplaySession = mutex.withLock {
|
||||
closeComplete?.await()
|
||||
|
||||
// Sessions should be closed by UI navigation, but just in case.
|
||||
activeSession?.closeBlocking()
|
||||
|
||||
closeComplete = CompletableDeferred()
|
||||
|
||||
NetplaySession(
|
||||
onClosed = {
|
||||
activeSession = null
|
||||
closeComplete?.complete(Unit)
|
||||
}
|
||||
).also {
|
||||
activeSession = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.runningFold
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress
|
||||
import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage
|
||||
import org.dolphinemu.dolphinemu.features.netplay.model.Player
|
||||
import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress
|
||||
import org.dolphinemu.dolphinemu.features.netplay.model.TraversalState
|
||||
import org.dolphinemu.dolphinemu.features.settings.model.StringSetting
|
||||
import org.dolphinemu.dolphinemu.model.GameFile
|
||||
import org.dolphinemu.dolphinemu.services.GameFileCacheManager
|
||||
|
||||
class NetplaySession(
|
||||
private val onClosed: (NetplaySession) -> Unit,
|
||||
) {
|
||||
|
||||
@Keep
|
||||
private var netPlayUICallbacksPointer: Long =
|
||||
nativeCreateUICallbacks(GameFileCacheManager.getGameFiles().value ?: emptyArray())
|
||||
|
||||
@Keep
|
||||
private var netPlayClientPointer: Long = 0
|
||||
|
||||
@Keep
|
||||
private var netPlayServerPointer: Long = 0
|
||||
|
||||
@Keep
|
||||
private var bootSessionDataPointer: Long = 0
|
||||
|
||||
private val sessionScope = CoroutineScope(SupervisorJob())
|
||||
|
||||
@Volatile
|
||||
var isClosed = false
|
||||
private set
|
||||
|
||||
val isHosting: Boolean
|
||||
get() = netPlayServerPointer != 0L
|
||||
|
||||
val isLaunching: Boolean
|
||||
get() = bootSessionDataPointer != 0L
|
||||
|
||||
val nickName by lazy { StringSetting.NETPLAY_NICKNAME.string }
|
||||
|
||||
private val _launchGame = Channel<String>(Channel.CONFLATED)
|
||||
val launchGame = _launchGame.receiveAsFlow()
|
||||
|
||||
private val _stopGame = Channel<Unit>(Channel.CONFLATED)
|
||||
val stopGame = _stopGame.receiveAsFlow()
|
||||
|
||||
private val _connectionLost = Channel<Unit>(Channel.CONFLATED)
|
||||
val connectionLost = _connectionLost.receiveAsFlow()
|
||||
|
||||
private val _connectionErrors = Channel<String>(Channel.BUFFERED)
|
||||
val connectionErrors = _connectionErrors.receiveAsFlow()
|
||||
|
||||
private val _messages = MutableSharedFlow<List<NetplayMessage>>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val messages = _messages.asSharedFlow()
|
||||
|
||||
private val _players = MutableSharedFlow<List<Player>>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val players = _players.asSharedFlow().distinctUntilChanged()
|
||||
|
||||
private val _chatMessages = MutableSharedFlow<String>(
|
||||
extraBufferCapacity = 32,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val chatMessages = _chatMessages.asSharedFlow()
|
||||
|
||||
private val _game = MutableSharedFlow<String>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val game = _game.asSharedFlow()
|
||||
|
||||
private val _hostInputAuthorityEnabled = MutableSharedFlow<Boolean>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val hostInputAuthorityEnabled = _hostInputAuthorityEnabled.asSharedFlow()
|
||||
|
||||
private val _padBuffer = MutableSharedFlow<Int>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val padBuffer = _padBuffer.asSharedFlow()
|
||||
|
||||
private val _desyncMessages = MutableSharedFlow<NetplayMessage.Desync>(
|
||||
extraBufferCapacity = 32,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val desyncMessages = _desyncMessages.asSharedFlow()
|
||||
|
||||
private val _saveTransferProgress = MutableStateFlow<SaveTransferProgress?>(null)
|
||||
val saveTransferProgress = _saveTransferProgress.asStateFlow()
|
||||
|
||||
private val _gameDigestProgress = MutableStateFlow<GameDigestProgress?>(null)
|
||||
val gameDigestProgress = _gameDigestProgress.asStateFlow()
|
||||
|
||||
private val _traversalState = MutableSharedFlow<TraversalState>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val traversalState = _traversalState.asSharedFlow()
|
||||
|
||||
private val _fatalTraversalError = Channel<TraversalState.Failure>(Channel.CONFLATED)
|
||||
val fatalTraversalError = _fatalTraversalError.receiveAsFlow()
|
||||
|
||||
suspend fun join(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (isClosed) throw IllegalStateException("Cannot join a closed session")
|
||||
|
||||
mergeMessages()
|
||||
.runningFold(emptyList<NetplayMessage>()) { acc, msg -> listOf(msg) + acc }
|
||||
.onEach { _messages.tryEmit(it) }
|
||||
.launchIn(sessionScope)
|
||||
|
||||
netPlayClientPointer = nativeJoin()
|
||||
|
||||
if (netPlayClientPointer == 0L || !isActive) {
|
||||
closeBlocking()
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
suspend fun host(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (isClosed) throw IllegalStateException("Cannot host a closed session")
|
||||
|
||||
netPlayServerPointer = nativeHost()
|
||||
if (netPlayServerPointer == 0L || !isActive) {
|
||||
closeBlocking()
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
join()
|
||||
}
|
||||
|
||||
fun sendMessage(message: String) {
|
||||
_chatMessages.tryEmit("$nickName: $message")
|
||||
nativeSendMessage(message)
|
||||
}
|
||||
|
||||
fun setHostInputAuthority(enable: Boolean) = nativeSetHostInputAuthority(enable)
|
||||
|
||||
fun adjustClientPadBufferSize(buffer: Int) = nativeAdjustClientPadBufferSize(buffer)
|
||||
|
||||
fun adjustServerPadBufferSize(buffer: Int) = nativeAdjustServerPadBufferSize(buffer)
|
||||
|
||||
fun changeGame(gameFile: GameFile) = nativeChangeGame(gameFile)
|
||||
|
||||
fun doAllPlayersHaveGame(): Boolean = nativeDoAllPlayersHaveGame()
|
||||
|
||||
fun startGame() = nativeStartGame()
|
||||
|
||||
fun getPort(): Int = nativeGetPort()
|
||||
|
||||
fun getExternalIpAddress(): String? = nativeGetExternalIpAddress()
|
||||
|
||||
fun reconnectTraversal() = nativeReconnectTraversal()
|
||||
|
||||
fun consumeBootSessionData(): Long {
|
||||
return bootSessionDataPointer.also {
|
||||
bootSessionDataPointer = 0
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun close() = withContext(Dispatchers.IO) {
|
||||
closeBlocking()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun closeBlocking() {
|
||||
if (isClosed) return
|
||||
isClosed = true
|
||||
sessionScope.cancel()
|
||||
releaseNativeResources()
|
||||
onClosed(this)
|
||||
}
|
||||
|
||||
protected fun finalize() {
|
||||
releaseNativeResources()
|
||||
}
|
||||
|
||||
private fun mergeMessages(): Flow<NetplayMessage> = merge(
|
||||
chatMessages.map { NetplayMessage.Chat(it) },
|
||||
game.map { NetplayMessage.GameChanged(it) },
|
||||
hostInputAuthorityEnabled.map { NetplayMessage.HostInputAuthorityChanged(it) },
|
||||
padBuffer.map { NetplayMessage.BufferChanged(it) },
|
||||
desyncMessages,
|
||||
)
|
||||
|
||||
private fun releaseNativeResources() {
|
||||
val currentBootSessionDataPointer = bootSessionDataPointer
|
||||
if (currentBootSessionDataPointer != 0L) {
|
||||
bootSessionDataPointer = 0
|
||||
nativeReleaseBootSessionData(currentBootSessionDataPointer)
|
||||
}
|
||||
|
||||
val currentNetPlayClientPointer = netPlayClientPointer
|
||||
if (currentNetPlayClientPointer != 0L) {
|
||||
netPlayClientPointer = 0
|
||||
nativeReleaseClient(currentNetPlayClientPointer)
|
||||
}
|
||||
|
||||
val currentNetPlayServerPointer = netPlayServerPointer
|
||||
if (currentNetPlayServerPointer != 0L) {
|
||||
netPlayServerPointer = 0
|
||||
nativeReleaseServer(currentNetPlayServerPointer)
|
||||
}
|
||||
|
||||
val currentNetPlayUICallbacksPointer = netPlayUICallbacksPointer
|
||||
if (currentNetPlayUICallbacksPointer != 0L) {
|
||||
netPlayUICallbacksPointer = 0
|
||||
nativeReleaseUICallbacks(currentNetPlayUICallbacksPointer)
|
||||
}
|
||||
}
|
||||
|
||||
// JNI methods
|
||||
|
||||
private external fun nativeCreateUICallbacks(gameFiles: Array<GameFile>): Long
|
||||
|
||||
private external fun nativeJoin(): Long
|
||||
|
||||
private external fun nativeHost(): Long
|
||||
|
||||
private external fun nativeSendMessage(message: String)
|
||||
|
||||
private external fun nativeSetHostInputAuthority(enable: Boolean)
|
||||
|
||||
private external fun nativeAdjustClientPadBufferSize(buffer: Int)
|
||||
|
||||
private external fun nativeAdjustServerPadBufferSize(buffer: Int)
|
||||
|
||||
private external fun nativeReleaseUICallbacks(pointer: Long)
|
||||
|
||||
private external fun nativeReleaseClient(pointer: Long)
|
||||
|
||||
private external fun nativeReleaseServer(pointer: Long)
|
||||
|
||||
private external fun nativeReleaseBootSessionData(pointer: Long)
|
||||
|
||||
private external fun nativeChangeGame(gameFile: GameFile)
|
||||
|
||||
private external fun nativeDoAllPlayersHaveGame(): Boolean
|
||||
|
||||
private external fun nativeStartGame()
|
||||
|
||||
private external fun nativeGetPort(): Int
|
||||
|
||||
private external fun nativeGetExternalIpAddress(): String?
|
||||
|
||||
private external fun nativeReconnectTraversal()
|
||||
|
||||
// NetPlayUI callbacks
|
||||
|
||||
@Keep
|
||||
fun onBootGame(gameFilePath: String, bootSessionDataPointer: Long) {
|
||||
this.bootSessionDataPointer = bootSessionDataPointer
|
||||
_stopGame.flush()
|
||||
_launchGame.trySend(gameFilePath)
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onStopGame() {
|
||||
_stopGame.trySend(Unit)
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onConnectionLost() {
|
||||
_connectionLost.trySend(Unit)
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onConnectionError(message: String) {
|
||||
_connectionErrors.trySend(message)
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onUpdate(players: Array<Player>) {
|
||||
_players.tryEmit(players.toList())
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onChatMessageReceived(message: String) {
|
||||
_chatMessages.tryEmit(message)
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onHostInputAuthorityChanged(enabled: Boolean) {
|
||||
_hostInputAuthorityEnabled.tryEmit(enabled)
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onGameChanged(game: String) {
|
||||
_game.tryEmit(game)
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onPadBufferChanged(buffer: Int) {
|
||||
if (_hostInputAuthorityEnabled.replayCache.firstOrNull() == true) return
|
||||
_padBuffer.tryEmit(buffer)
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onDesync(frame: Int, player: String) {
|
||||
_desyncMessages.tryEmit(NetplayMessage.Desync(player, frame))
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onShowChunkedProgressDialog(title: String, dataSize: Long, playerIds: IntArray) {
|
||||
val players = _players.replayCache.firstOrNull()
|
||||
_saveTransferProgress.value = SaveTransferProgress(
|
||||
title = title,
|
||||
totalSize = dataSize,
|
||||
playerProgresses = playerIds.map { playerId ->
|
||||
SaveTransferProgress.PlayerProgress(
|
||||
playerId = playerId,
|
||||
name = players?.find { it.pid == playerId }?.name ?: "Invalid Player ID",
|
||||
progress = 0,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onSetChunkedProgress(playerId: Int, progress: Long) {
|
||||
val current = _saveTransferProgress.value
|
||||
_saveTransferProgress.value = current?.copy(
|
||||
playerProgresses = current.playerProgresses.map {
|
||||
if (it.playerId == playerId) {
|
||||
it.copy(progress = progress)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onHideChunkedProgressDialog() {
|
||||
_saveTransferProgress.value = null
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onShowGameDigestDialog(title: String) {
|
||||
val players = _players.replayCache.firstOrNull()
|
||||
_gameDigestProgress.value = GameDigestProgress(
|
||||
title = title,
|
||||
playerProgresses = players?.map { player ->
|
||||
GameDigestProgress.PlayerProgress(
|
||||
playerId = player.pid,
|
||||
name = player.name,
|
||||
progress = 0,
|
||||
result = null,
|
||||
)
|
||||
} ?: emptyList(),
|
||||
matches = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onSetGameDigestProgress(playerId: Int, progress: Int) {
|
||||
val current = _gameDigestProgress.value ?: return
|
||||
_gameDigestProgress.value = current.copy(
|
||||
playerProgresses = current.playerProgresses.map {
|
||||
if (it.playerId == playerId) it.copy(progress = progress) else it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onSetGameDigestResult(playerId: Int, result: String) {
|
||||
val current = _gameDigestProgress.value ?: return
|
||||
val updated = current.copy(
|
||||
playerProgresses = current.playerProgresses.map {
|
||||
if (it.playerId == playerId) it.copy(result = result) else it
|
||||
}
|
||||
)
|
||||
val finished = updated.playerProgresses.all { it.result != null }
|
||||
_gameDigestProgress.value = if (finished) {
|
||||
val results = updated.playerProgresses.map { it.result }
|
||||
updated.copy(matches = results.distinct().size == 1)
|
||||
} else {
|
||||
updated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts send this when they dismiss their dialog even in a successful scenario. Ensuring
|
||||
* that the value is cleared before a new game digest is started. Without this, StateFlow
|
||||
* would not be a good choice.
|
||||
*/
|
||||
@Keep
|
||||
fun onAbortGameDigest() {
|
||||
_gameDigestProgress.value = null
|
||||
}
|
||||
|
||||
@Keep
|
||||
fun onTraversalStateChanged(
|
||||
state: Int,
|
||||
hostCode: String?,
|
||||
externalAddress: String?,
|
||||
failureReason: String?,
|
||||
) {
|
||||
val traversalState = when (state) {
|
||||
0 -> TraversalState.Connecting
|
||||
1 -> TraversalState.Connected(hostCode!!, externalAddress!!)
|
||||
2 -> TraversalState.Failure(failureReason!!)
|
||||
else -> return
|
||||
}
|
||||
_traversalState.tryEmit(traversalState)
|
||||
|
||||
if (failureReason == "BadHost" || failureReason == "VersionTooOld") {
|
||||
_fatalTraversalError.trySend(TraversalState.Failure(failureReason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> Channel<T>.flush() {
|
||||
while (this.tryReceive().isSuccess) Unit
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.dolphinemu.dolphinemu.R
|
||||
|
||||
sealed class ConnectionRole(
|
||||
@StringRes val labelId: Int,
|
||||
@StringRes val loadingLabelId: Int,
|
||||
) {
|
||||
object Connect : ConnectionRole(
|
||||
labelId = R.string.netplay_connection_role_connect,
|
||||
loadingLabelId = R.string.netplay_connection_role_connect_loading,
|
||||
)
|
||||
|
||||
object Host : ConnectionRole(
|
||||
labelId = R.string.netplay_connection_role_host,
|
||||
loadingLabelId = R.string.netplay_connection_role_host_loading,
|
||||
)
|
||||
|
||||
companion object {
|
||||
val all: List<ConnectionRole>
|
||||
get() = listOf(Connect, Host)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.dolphinemu.dolphinemu.R
|
||||
|
||||
sealed class ConnectionType(
|
||||
@StringRes val labelId: Int,
|
||||
val configValue: String,
|
||||
) {
|
||||
object DirectConnection : ConnectionType(
|
||||
labelId = R.string.netplay_connection_type_direct_connection,
|
||||
configValue = "direct",
|
||||
)
|
||||
|
||||
object TraversalServer : ConnectionType(
|
||||
labelId = R.string.netplay_connection_type_traversal_server,
|
||||
configValue = "traversal",
|
||||
)
|
||||
|
||||
companion object {
|
||||
val all: List<ConnectionType>
|
||||
get() = listOf(DirectConnection, TraversalServer)
|
||||
|
||||
fun fromString(value: String): ConnectionType =
|
||||
all.find { it.configValue == value } ?: throw IllegalArgumentException("Invalid connection type: $value")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay.model
|
||||
|
||||
data class GameDigestProgress(
|
||||
val title: String,
|
||||
val playerProgresses: List<PlayerProgress>,
|
||||
val matches: Boolean?,
|
||||
) {
|
||||
data class PlayerProgress(
|
||||
val playerId: Int,
|
||||
val name: String,
|
||||
val progress: Int,
|
||||
val result: String?,
|
||||
)
|
||||
}
|
||||
@@ -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,36 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay.model
|
||||
|
||||
import android.content.Context
|
||||
import org.dolphinemu.dolphinemu.R
|
||||
|
||||
sealed class NetplayMessage {
|
||||
abstract fun message(context: Context): String
|
||||
|
||||
class Chat(private val chatMessage: String) : NetplayMessage() {
|
||||
override fun message(context: Context) = chatMessage
|
||||
}
|
||||
|
||||
class GameChanged(private val game: String) : NetplayMessage() {
|
||||
override fun message(context: Context) =
|
||||
context.getString(R.string.netplay_message_game_changed, game)
|
||||
}
|
||||
|
||||
class HostInputAuthorityChanged(private val hostInputAuthorityEnabled: Boolean) : NetplayMessage() {
|
||||
override fun message(context: Context) = context.getString(
|
||||
R.string.netplay_message_host_input_authority_changed,
|
||||
if (hostInputAuthorityEnabled) "enabled" else "disabled"
|
||||
)
|
||||
}
|
||||
|
||||
class BufferChanged(private val buffer: Int) : NetplayMessage() {
|
||||
override fun message(context: Context) =
|
||||
context.getString(R.string.netplay_message_buffer_changed, buffer)
|
||||
}
|
||||
|
||||
class Desync(private val player: String, private val frame: Int) : NetplayMessage() {
|
||||
override fun message(context: Context) =
|
||||
context.getString(R.string.netplay_message_desync, player, frame)
|
||||
}
|
||||
}
|
||||
@@ -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 _notAllPlayersHaveGame = Channel<Unit>(Channel.CONFLATED)
|
||||
val notAllPlayersHaveGame = _notAllPlayersHaveGame.receiveAsFlow()
|
||||
|
||||
fun startGame() {
|
||||
if (netplaySession.doAllPlayersHaveGame()) {
|
||||
netplaySession.startGame()
|
||||
} else {
|
||||
_notAllPlayersHaveGame.trySend(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmStartGame() {
|
||||
netplaySession.startGame()
|
||||
}
|
||||
|
||||
fun sendMessage(message: String) {
|
||||
val trimmedMessage = message.trim()
|
||||
if (trimmedMessage.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
netplaySession.sendMessage(trimmedMessage)
|
||||
}
|
||||
|
||||
fun setNetworkMode(mode: NetworkMode) {
|
||||
_networkMode.value = mode
|
||||
StringSetting.NETPLAY_NETWORK_MODE.setString(NativeConfig.LAYER_BASE, mode.configValue)
|
||||
netplaySession.setHostInputAuthority(mode.isHostInputAuthority)
|
||||
}
|
||||
|
||||
fun setBuffer(value: Int) {
|
||||
_buffer.value = value
|
||||
IntSetting.NETPLAY_BUFFER_SIZE.setInt(NativeConfig.LAYER_BASE, value)
|
||||
netplaySession.adjustServerPadBufferSize(value)
|
||||
}
|
||||
|
||||
fun setClientBuffer(value: Int) {
|
||||
_clientBuffer.value = value
|
||||
IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.setInt(NativeConfig.LAYER_BASE, value)
|
||||
netplaySession.adjustClientPadBufferSize(value)
|
||||
}
|
||||
|
||||
fun changeGame(gameFile: GameFile) {
|
||||
StringSetting.NETPLAY_GAME.setString(NativeConfig.LAYER_BASE, gameFile.getGameId())
|
||||
netplaySession.changeGame(gameFile)
|
||||
}
|
||||
|
||||
private fun getLocalIp(): JoinAddress {
|
||||
val localIp = networkHelper.getLocalIpString()
|
||||
?: return JoinAddress.Unknown { _joinAddresses.value += JoinInfoType.LOCAL to getLocalIp() }
|
||||
val port = netplaySession.getPort()
|
||||
return JoinAddress.Loaded("$localIp:$port")
|
||||
}
|
||||
|
||||
private fun fetchExternalIp() {
|
||||
_joinAddresses.value += JoinInfoType.EXTERNAL to JoinAddress.Loading
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val ip = netplaySession.getExternalIpAddress()
|
||||
val port = netplaySession.getPort()
|
||||
val address = if (ip != null) JoinAddress.Loaded("$ip:$port")
|
||||
else JoinAddress.Unknown { fetchExternalIp() }
|
||||
_joinAddresses.value += JoinInfoType.EXTERNAL to address
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectTraversalState() {
|
||||
val retry = { netplaySession.reconnectTraversal() }
|
||||
netplaySession.traversalState.onEach { state ->
|
||||
when (state) {
|
||||
is TraversalState.Connecting -> {
|
||||
_joinAddresses.value += mapOf(
|
||||
JoinInfoType.ROOM_ID to JoinAddress.Loading,
|
||||
JoinInfoType.EXTERNAL to JoinAddress.Loading,
|
||||
)
|
||||
}
|
||||
is TraversalState.Connected -> {
|
||||
_joinAddresses.value += mapOf(
|
||||
JoinInfoType.ROOM_ID to JoinAddress.Loaded(state.hostCode),
|
||||
JoinInfoType.EXTERNAL to JoinAddress.Loaded(state.externalAddress),
|
||||
)
|
||||
}
|
||||
is TraversalState.Failure -> {
|
||||
_joinAddresses.value += mapOf(
|
||||
JoinInfoType.ROOM_ID to JoinAddress.Unknown(retry),
|
||||
JoinInfoType.EXTERNAL to JoinAddress.Unknown(retry),
|
||||
)
|
||||
}
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun setInitialGame() {
|
||||
val game = gameFiles.value
|
||||
.find { it.getGameId() == StringSetting.NETPLAY_GAME.string }
|
||||
?: gameFiles.value.firstOrNull()
|
||||
|
||||
if (game != null) {
|
||||
changeGame(game)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// Closing the netplay session is a bit slow for the main thread so launch in
|
||||
// GlobalScope and allow the activity and view model to finish immediately.
|
||||
GlobalScope.launch {
|
||||
netplaySession.close()
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val session: NetplaySession,
|
||||
private val networkHelper: NetworkHelper,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return NetplayViewModel(session, networkHelper) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.dolphinemu.dolphinemu.R
|
||||
|
||||
enum class NetworkMode(val configValue: String, @StringRes val labelId: Int) {
|
||||
FAIR_INPUT_DELAY("fixeddelay", R.string.netplay_network_mode_fair_input_delay),
|
||||
HOST_INPUT_AUTHORITY("hostinputauthority", R.string.netplay_network_mode_host_input_authority),
|
||||
GOLF("golf", R.string.netplay_network_mode_golf);
|
||||
|
||||
val isHostInputAuthority: Boolean
|
||||
get() = this == HOST_INPUT_AUTHORITY || this == GOLF
|
||||
|
||||
companion object {
|
||||
fun fromConfigValue(value: String): NetworkMode =
|
||||
entries.find { it.configValue == value } ?: FAIR_INPUT_DELAY
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay.model
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
data class Player(
|
||||
val pid: Int,
|
||||
val name: String,
|
||||
val revision: String,
|
||||
val ping: Int,
|
||||
val isHost: Boolean,
|
||||
val mapping: String,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay.model
|
||||
|
||||
data class SaveTransferProgress(
|
||||
val title: String,
|
||||
val totalSize: Long,
|
||||
val playerProgresses: List<PlayerProgress>
|
||||
) {
|
||||
data class PlayerProgress(
|
||||
val playerId: Int,
|
||||
val name: String,
|
||||
val progress: Long,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay.model
|
||||
|
||||
import android.content.Context
|
||||
import org.dolphinemu.dolphinemu.R
|
||||
|
||||
sealed class TraversalState {
|
||||
data object Connecting : TraversalState()
|
||||
|
||||
data class Connected(val hostCode: String, val externalAddress: String) : TraversalState()
|
||||
|
||||
data class Failure(val reason: String) : TraversalState() {
|
||||
fun message(context: Context) = when (reason) {
|
||||
"BadHost" -> context.getString(R.string.netplay_traversal_error_bad_host)
|
||||
"VersionTooOld" -> context.getString(R.string.netplay_traversal_error_version_too_old)
|
||||
else -> reason
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.dolphinemu.dolphinemu.activities.EmulationActivity
|
||||
import org.dolphinemu.dolphinemu.features.netplay.NetplayManager
|
||||
import org.dolphinemu.dolphinemu.features.netplay.model.NetplayViewModel
|
||||
import org.dolphinemu.dolphinemu.ui.main.ThemeProvider
|
||||
import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme
|
||||
import org.dolphinemu.dolphinemu.utils.NetworkHelper
|
||||
import org.dolphinemu.dolphinemu.utils.ThemeHelper
|
||||
|
||||
class NetplayActivity : AppCompatActivity(), ThemeProvider {
|
||||
override var themeId: Int = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
ThemeHelper.setTheme(this)
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val session = NetplayManager.activeSession
|
||||
if (session == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
val viewModel = ViewModelProvider(this, NetplayViewModel.Factory(session, NetworkHelper))[NetplayViewModel::class.java]
|
||||
|
||||
viewModel.launchGame
|
||||
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
|
||||
.onEach { EmulationActivity.launch(this, it, false) }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
setContent {
|
||||
DolphinTheme {
|
||||
NetplayScreen(
|
||||
onBackClicked = { finish() },
|
||||
isHosting = viewModel.isHosting,
|
||||
connectionLost = viewModel.connectionLost,
|
||||
fatalTraversalError = viewModel.fatalTraversalError,
|
||||
messages = viewModel.messages.collectAsState().value,
|
||||
onSendMessage = viewModel::sendMessage,
|
||||
game = viewModel.game.collectAsState().value,
|
||||
onStartGame = viewModel::startGame,
|
||||
onGameSelected = viewModel::changeGame,
|
||||
gameFiles = viewModel.gameFiles.collectAsState().value,
|
||||
notAllPlayersHaveGame = viewModel.notAllPlayersHaveGame,
|
||||
onConfirmStartGame = viewModel::confirmStartGame,
|
||||
players = viewModel.players.collectAsState().value,
|
||||
hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value,
|
||||
networkMode = viewModel.networkMode.collectAsState().value,
|
||||
onNetworkModeChanged = viewModel::setNetworkMode,
|
||||
buffer = viewModel.buffer.collectAsState().value,
|
||||
onBufferChanged = viewModel::setBuffer,
|
||||
clientBuffer = viewModel.clientBuffer.collectAsState().value,
|
||||
onClientBufferChanged = viewModel::setClientBuffer,
|
||||
saveTransferProgress = viewModel.saveTransferProgress.collectAsState().value,
|
||||
gameDigestProgress = viewModel.gameDigestProgress.collectAsState().value,
|
||||
joinAddresses = viewModel.joinAddresses.collectAsState().value,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setTheme(themeId: Int) {
|
||||
super.setTheme(themeId)
|
||||
this.themeId = themeId
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
ThemeHelper.setCorrectTheme(this)
|
||||
super.onResume()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun launch(context: Context) {
|
||||
context.startActivity(Intent(context, NetplayActivity::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
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,415 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.dolphinemu.dolphinemu.features.netplay.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SecondaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import org.dolphinemu.dolphinemu.R
|
||||
import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionRole
|
||||
import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType
|
||||
import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme
|
||||
import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer
|
||||
|
||||
private data class ErrorDialogState(val message: String) {
|
||||
val onDismissed = CompletableDeferred<Unit>()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NetplaySetupScreen(
|
||||
onBackClicked: () -> Unit,
|
||||
connecting: Boolean,
|
||||
errors: Flow<String>,
|
||||
connectionRole: ConnectionRole,
|
||||
onConnectionRoleChanged: (ConnectionRole) -> Unit,
|
||||
nickname: String,
|
||||
onNicknameChanged: (String) -> Unit,
|
||||
connectionType: ConnectionType,
|
||||
onConnectionTypeChanged: (ConnectionType) -> Unit,
|
||||
ipAddress: String,
|
||||
onIpAddressChanged: (String) -> Unit,
|
||||
connectPort: String,
|
||||
onConnectPortChanged: (String) -> Unit,
|
||||
hostCode: String,
|
||||
onHostCodeChanged: (String) -> Unit,
|
||||
hostPort: String,
|
||||
onHostPortChanged: (String) -> Unit,
|
||||
useUpnp: Boolean,
|
||||
onUseUpnpChanged: (Boolean) -> Unit,
|
||||
onHostClicked: () -> Unit,
|
||||
onConnectClicked: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MediumTopAppBar(
|
||||
title = { Text(stringResource(R.string.netplay_setup_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClicked) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = when(connectionRole) {
|
||||
ConnectionRole.Host -> onHostClicked
|
||||
ConnectionRole.Connect -> onConnectClicked
|
||||
},
|
||||
) {
|
||||
if (connecting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(stringResource(connectionRole.loadingLabelId))
|
||||
} else {
|
||||
Text(stringResource(connectionRole.labelId))
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
var activeErrorDialog by remember { mutableStateOf<ErrorDialogState?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
errors.collect { message ->
|
||||
activeErrorDialog = ErrorDialogState(message)
|
||||
activeErrorDialog?.onDismissed?.await()
|
||||
activeErrorDialog = null
|
||||
}
|
||||
}
|
||||
activeErrorDialog?.let { activeErrorDialog ->
|
||||
AlertDialog(
|
||||
text = { Text(activeErrorDialog.message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { activeErrorDialog.onDismissed.complete(Unit) }) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
},
|
||||
onDismissRequest = { activeErrorDialog.onDismissed.complete(Unit) },
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(innerPadding)
|
||||
.padding(bottom = DolphinTheme.fabClearancePadding)
|
||||
) {
|
||||
SecondaryTabRow(selectedTabIndex = ConnectionRole.all.indexOf(connectionRole)) {
|
||||
ConnectionRole.all.forEach { role ->
|
||||
Tab(
|
||||
selected = connectionRole == role,
|
||||
onClick = { onConnectionRoleChanged(role) },
|
||||
text = { Text(stringResource(role.labelId)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MenuSpacer()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = DolphinTheme.scaffoldPadding)
|
||||
) {
|
||||
NetplaySetupContent(
|
||||
nickname = nickname,
|
||||
onNicknameChanged = onNicknameChanged,
|
||||
connectionType = connectionType,
|
||||
onConnectionTypeChanged = onConnectionTypeChanged,
|
||||
ipAddress = ipAddress,
|
||||
onIpAddressChanged = onIpAddressChanged,
|
||||
hostCode = hostCode,
|
||||
onHostCodeChanged = onHostCodeChanged,
|
||||
connectPort = connectPort,
|
||||
onConnectPortChanged = onConnectPortChanged,
|
||||
hostPort = hostPort,
|
||||
onHostPortChanged = onHostPortChanged,
|
||||
useUpnp = useUpnp,
|
||||
onUseUpnpChanged = onUseUpnpChanged,
|
||||
connectionRole = connectionRole,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NetplaySetupContent(
|
||||
nickname: String,
|
||||
onNicknameChanged: (String) -> Unit,
|
||||
connectionType: ConnectionType,
|
||||
onConnectionTypeChanged: (ConnectionType) -> Unit,
|
||||
ipAddress: String,
|
||||
onIpAddressChanged: (String) -> Unit,
|
||||
hostCode: String,
|
||||
onHostCodeChanged: (String) -> Unit,
|
||||
connectPort: String,
|
||||
onConnectPortChanged: (String) -> Unit,
|
||||
hostPort: String,
|
||||
onHostPortChanged: (String) -> Unit,
|
||||
useUpnp: Boolean,
|
||||
onUseUpnpChanged: (Boolean) -> Unit,
|
||||
connectionRole: ConnectionRole,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = nickname,
|
||||
onValueChange = onNicknameChanged,
|
||||
label = { Text(stringResource(R.string.netplay_nickname_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
MenuSpacer()
|
||||
|
||||
ConnectionTypePicker(
|
||||
connectionType = connectionType,
|
||||
onConnectionTypeChanged = onConnectionTypeChanged,
|
||||
)
|
||||
|
||||
MenuSpacer()
|
||||
|
||||
when (connectionRole) {
|
||||
ConnectionRole.Connect -> ConnectMenu(
|
||||
connectionType = connectionType,
|
||||
ipAddress = ipAddress,
|
||||
onIpAddressChanged = onIpAddressChanged,
|
||||
hostCode = hostCode,
|
||||
onHostCodeChanged = onHostCodeChanged,
|
||||
port = connectPort,
|
||||
onPortChanged = onConnectPortChanged,
|
||||
)
|
||||
|
||||
ConnectionRole.Host -> HostMenu(
|
||||
connectionType = connectionType,
|
||||
hostPort = hostPort,
|
||||
onHostPortChanged = onHostPortChanged,
|
||||
useUpnp = useUpnp,
|
||||
onUseUpnpChanged = onUseUpnpChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionTypePicker(
|
||||
connectionType: ConnectionType,
|
||||
onConnectionTypeChanged: (ConnectionType) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
) {
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
ConnectionType.all.forEach { connectionType ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(connectionType.labelId)) },
|
||||
onClick = {
|
||||
onConnectionTypeChanged(connectionType)
|
||||
expanded = false
|
||||
},
|
||||
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = stringResource(connectionType.labelId),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.netplay_connection_type)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectMenu(
|
||||
connectionType: ConnectionType,
|
||||
ipAddress: String,
|
||||
onIpAddressChanged: (String) -> Unit,
|
||||
hostCode: String,
|
||||
onHostCodeChanged: (String) -> Unit,
|
||||
port: String,
|
||||
onPortChanged: (String) -> Unit,
|
||||
) {
|
||||
when (connectionType) {
|
||||
ConnectionType.DirectConnection -> {
|
||||
OutlinedTextField(
|
||||
value = ipAddress,
|
||||
onValueChange = onIpAddressChanged,
|
||||
label = { Text(stringResource(R.string.netplay_ip_address_label)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
MenuSpacer()
|
||||
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = onPortChanged,
|
||||
label = { Text(stringResource(R.string.netplay_port_label)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
ConnectionType.TraversalServer -> OutlinedTextField(
|
||||
value = hostCode,
|
||||
onValueChange = onHostCodeChanged,
|
||||
label = { Text(stringResource(R.string.netplay_host_code_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HostMenu(
|
||||
connectionType: ConnectionType,
|
||||
hostPort: String,
|
||||
onHostPortChanged: (String) -> Unit,
|
||||
useUpnp: Boolean,
|
||||
onUseUpnpChanged: (Boolean) -> Unit,
|
||||
) {
|
||||
if (connectionType == ConnectionType.DirectConnection) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = hostPort,
|
||||
onValueChange = onHostPortChanged,
|
||||
label = { Text(stringResource(R.string.netplay_host_port_label)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onUseUpnpChanged(!useUpnp) },
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
modifier = Modifier
|
||||
.height(64.dp)
|
||||
.padding(top = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.netplay_use_upnp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Checkbox(
|
||||
checked = useUpnp,
|
||||
onCheckedChange = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MenuSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun NetplaySetupScreenPreview() {
|
||||
MaterialTheme {
|
||||
NetplaySetupScreen(
|
||||
onBackClicked = {},
|
||||
connecting = false,
|
||||
errors = emptyFlow(),
|
||||
connectionRole = ConnectionRole.Host,
|
||||
onConnectionRoleChanged = {},
|
||||
nickname = "Preview nickname",
|
||||
onNicknameChanged = {},
|
||||
connectionType = ConnectionType.DirectConnection,
|
||||
onConnectionTypeChanged = {},
|
||||
ipAddress = "127.0.0.1",
|
||||
onIpAddressChanged = {},
|
||||
connectPort = "2626",
|
||||
onConnectPortChanged = {},
|
||||
hostCode = "",
|
||||
onHostCodeChanged = {},
|
||||
hostPort = "2626",
|
||||
onHostPortChanged = {},
|
||||
useUpnp = false,
|
||||
onUseUpnpChanged = {},
|
||||
onHostClicked = {},
|
||||
onConnectClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -934,7 +934,8 @@ enum class BooleanSetting(
|
||||
Settings.SECTION_ACHIEVEMENTS,
|
||||
"ProgressEnabled",
|
||||
false
|
||||
);
|
||||
),
|
||||
NETPLAY_USE_UPNP(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "UseUPNP", false);
|
||||
|
||||
override val isOverridden: Boolean
|
||||
get() = NativeConfig.isOverridden(file, section, key)
|
||||
|
||||
@@ -162,7 +162,21 @@ enum class IntSetting(
|
||||
WIIMOTE_2_SOURCE(Settings.FILE_WIIMOTE, "Wiimote2", "Source", 0),
|
||||
WIIMOTE_3_SOURCE(Settings.FILE_WIIMOTE, "Wiimote3", "Source", 0),
|
||||
WIIMOTE_4_SOURCE(Settings.FILE_WIIMOTE, "Wiimote4", "Source", 0),
|
||||
WIIMOTE_BB_SOURCE(Settings.FILE_WIIMOTE, "BalanceBoard", "Source", 0);
|
||||
WIIMOTE_BB_SOURCE(Settings.FILE_WIIMOTE, "BalanceBoard", "Source", 0),
|
||||
NETPLAY_CONNECT_PORT(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "ConnectPort", 2626),
|
||||
NETPLAY_HOST_PORT(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "HostPort", 2626),
|
||||
NETPLAY_CLIENT_BUFFER_SIZE(
|
||||
Settings.FILE_DOLPHIN,
|
||||
Settings.SECTION_INI_NETPLAY,
|
||||
"BufferSizeClient",
|
||||
1
|
||||
),
|
||||
NETPLAY_BUFFER_SIZE(
|
||||
Settings.FILE_DOLPHIN,
|
||||
Settings.SECTION_INI_NETPLAY,
|
||||
"BufferSize",
|
||||
5
|
||||
);
|
||||
|
||||
override val isOverridden: Boolean
|
||||
get() = NativeConfig.isOverridden(file, section, key)
|
||||
|
||||
@@ -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,337 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.dolphinemu.dolphinemu.ui.theme
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.SheetValue.Hidden
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import androidx.appcompat.R as AppCompatR
|
||||
import com.google.android.material.R as MaterialR
|
||||
|
||||
object DolphinTheme {
|
||||
val scaffoldPadding = 16.dp
|
||||
val fabClearancePadding = 80.dp
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DolphinTheme(content: @Composable () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val colorScheme = remember(context, isDark) { context.toDolphinColorScheme(isDark) }
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreviewTheme(
|
||||
darkTheme: Boolean,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
private fun Context.toDolphinColorScheme(isDark: Boolean): ColorScheme {
|
||||
fun attr(@AttrRes attr: Int) = Color(MaterialColors.getColor(this, attr, 0))
|
||||
|
||||
val background = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground)).use {
|
||||
Color(it.getColor(0, 0))
|
||||
}
|
||||
|
||||
return if (isDark) {
|
||||
darkColorScheme(
|
||||
primary = attr(AppCompatR.attr.colorPrimary),
|
||||
onPrimary = attr(MaterialR.attr.colorOnPrimary),
|
||||
primaryContainer = attr(MaterialR.attr.colorPrimaryContainer),
|
||||
onPrimaryContainer = attr(MaterialR.attr.colorOnPrimaryContainer),
|
||||
secondary = attr(MaterialR.attr.colorSecondary),
|
||||
onSecondary = attr(MaterialR.attr.colorOnSecondary),
|
||||
secondaryContainer = attr(MaterialR.attr.colorSecondaryContainer),
|
||||
onSecondaryContainer = attr(MaterialR.attr.colorOnSecondaryContainer),
|
||||
tertiary = attr(MaterialR.attr.colorTertiary),
|
||||
onTertiary = attr(MaterialR.attr.colorOnTertiary),
|
||||
tertiaryContainer = attr(MaterialR.attr.colorTertiaryContainer),
|
||||
onTertiaryContainer = attr(MaterialR.attr.colorOnTertiaryContainer),
|
||||
error = attr(AppCompatR.attr.colorError),
|
||||
onError = attr(MaterialR.attr.colorOnError),
|
||||
errorContainer = attr(MaterialR.attr.colorErrorContainer),
|
||||
onErrorContainer = attr(MaterialR.attr.colorOnErrorContainer),
|
||||
background = background,
|
||||
onBackground = attr(MaterialR.attr.colorOnBackground),
|
||||
surface = attr(MaterialR.attr.colorSurface),
|
||||
onSurface = attr(MaterialR.attr.colorOnSurface),
|
||||
surfaceVariant = attr(MaterialR.attr.colorSurfaceVariant),
|
||||
onSurfaceVariant = attr(MaterialR.attr.colorOnSurfaceVariant),
|
||||
outline = attr(MaterialR.attr.colorOutline),
|
||||
inverseSurface = attr(MaterialR.attr.colorSurfaceInverse),
|
||||
inverseOnSurface = attr(MaterialR.attr.colorOnSurfaceInverse),
|
||||
inversePrimary = attr(MaterialR.attr.colorPrimaryInverse),
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
primary = attr(AppCompatR.attr.colorPrimary),
|
||||
onPrimary = attr(MaterialR.attr.colorOnPrimary),
|
||||
primaryContainer = attr(MaterialR.attr.colorPrimaryContainer),
|
||||
onPrimaryContainer = attr(MaterialR.attr.colorOnPrimaryContainer),
|
||||
secondary = attr(MaterialR.attr.colorSecondary),
|
||||
onSecondary = attr(MaterialR.attr.colorOnSecondary),
|
||||
secondaryContainer = attr(MaterialR.attr.colorSecondaryContainer),
|
||||
onSecondaryContainer = attr(MaterialR.attr.colorOnSecondaryContainer),
|
||||
tertiary = attr(MaterialR.attr.colorTertiary),
|
||||
onTertiary = attr(MaterialR.attr.colorOnTertiary),
|
||||
tertiaryContainer = attr(MaterialR.attr.colorTertiaryContainer),
|
||||
onTertiaryContainer = attr(MaterialR.attr.colorOnTertiaryContainer),
|
||||
error = attr(AppCompatR.attr.colorError),
|
||||
onError = attr(MaterialR.attr.colorOnError),
|
||||
errorContainer = attr(MaterialR.attr.colorErrorContainer),
|
||||
onErrorContainer = attr(MaterialR.attr.colorOnErrorContainer),
|
||||
background = background,
|
||||
onBackground = attr(MaterialR.attr.colorOnBackground),
|
||||
surface = attr(MaterialR.attr.colorSurface),
|
||||
onSurface = attr(MaterialR.attr.colorOnSurface),
|
||||
surfaceVariant = attr(MaterialR.attr.colorSurfaceVariant),
|
||||
onSurfaceVariant = attr(MaterialR.attr.colorOnSurfaceVariant),
|
||||
outline = attr(MaterialR.attr.colorOutline),
|
||||
inverseSurface = attr(MaterialR.attr.colorSurfaceInverse),
|
||||
inverseOnSurface = attr(MaterialR.attr.colorOnSurfaceInverse),
|
||||
inversePrimary = attr(MaterialR.attr.colorPrimaryInverse),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MenuSpacer() = Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OutlinedBox(
|
||||
label: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
fadeContentTop: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(top = 8.dp)
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
OutlinedTextFieldDefaults.DecorationBox(
|
||||
value = "chatText",
|
||||
innerTextField = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
content()
|
||||
if (fadeContentTop) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(16.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.surface,
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = true,
|
||||
singleLine = false,
|
||||
contentPadding = if (fadeContentTop) {
|
||||
OutlinedTextFieldDefaults.contentPadding(top = 0.dp)
|
||||
} else {
|
||||
OutlinedTextFieldDefaults.contentPadding()
|
||||
},
|
||||
visualTransformation = VisualTransformation.None,
|
||||
interactionSource = interactionSource,
|
||||
label = { label() },
|
||||
container = {
|
||||
OutlinedTextFieldDefaults.Container(
|
||||
enabled = true,
|
||||
isError = false,
|
||||
interactionSource = interactionSource,
|
||||
colors = OutlinedTextFieldDefaults.colors(),
|
||||
)
|
||||
}
|
||||
)
|
||||
if (onClick != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = LocalIndication.current,
|
||||
onClick = onClick,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReadOnlyTextField(
|
||||
value: String,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
textStyle: TextStyle? = null,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
label = { Text(label) },
|
||||
trailingIcon = trailingIcon,
|
||||
textStyle = textStyle ?: LocalTextStyle.current,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (onClick != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.padding(top = 8.dp)
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.clickable(onClick = onClick)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A copy-paste of the internal function used in rememberModalBottomSheetState since
|
||||
// rememberModalBottomSheetState doesn't expose a way to set the initial value.
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun rememberSheetState(
|
||||
skipPartiallyExpanded: Boolean = false,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
initialValue: SheetValue = Hidden,
|
||||
skipHiddenState: Boolean = false,
|
||||
positionalThreshold: Dp = 56.dp,
|
||||
velocityThreshold: Dp = 125.dp,
|
||||
): SheetState {
|
||||
val density = LocalDensity.current
|
||||
val positionalThresholdToPx = { with(density) { positionalThreshold.toPx() } }
|
||||
val velocityThresholdToPx = { with(density) { velocityThreshold.toPx() } }
|
||||
return rememberSaveable(
|
||||
skipPartiallyExpanded,
|
||||
confirmValueChange,
|
||||
skipHiddenState,
|
||||
saver =
|
||||
SheetState.Saver(
|
||||
skipPartiallyExpanded = skipPartiallyExpanded,
|
||||
positionalThreshold = positionalThresholdToPx,
|
||||
velocityThreshold = velocityThresholdToPx,
|
||||
confirmValueChange = confirmValueChange,
|
||||
skipHiddenState = skipHiddenState,
|
||||
),
|
||||
) {
|
||||
SheetState(
|
||||
skipPartiallyExpanded,
|
||||
positionalThresholdToPx,
|
||||
velocityThresholdToPx,
|
||||
initialValue,
|
||||
confirmValueChange,
|
||||
skipHiddenState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.bottomFadeOverlay(
|
||||
scrollState: ScrollState,
|
||||
fadeHeight: Dp,
|
||||
): Modifier {
|
||||
val surfaceColor = MaterialTheme.colorScheme.surface
|
||||
val bottomPaddingPx = with(LocalDensity.current) {
|
||||
fadeHeight.toPx()
|
||||
}
|
||||
val fadeDelayPx = with(LocalDensity.current) { 8.dp.toPx() }
|
||||
val fadeDurationPx = with(LocalDensity.current) { 48.dp.toPx() }
|
||||
val scrollProgress by remember {
|
||||
derivedStateOf {
|
||||
if (bottomPaddingPx <= 0f) 1f
|
||||
else ((scrollState.value.coerceAtLeast(0) - fadeDelayPx) / fadeDurationPx)
|
||||
.coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
val surfaceStopFraction = (scrollProgress * 2f).coerceIn(0f, 1f)
|
||||
val transparentStopFraction = ((scrollProgress * 2f) - 1f).coerceIn(0f, 1f)
|
||||
|
||||
return drawWithContent {
|
||||
drawContent()
|
||||
if (transparentStopFraction < 1f) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0f to Color.Transparent,
|
||||
transparentStopFraction to Color.Transparent,
|
||||
surfaceStopFraction to surfaceColor,
|
||||
1f to surfaceColor,
|
||||
),
|
||||
startY = size.height - bottomPaddingPx,
|
||||
endY = size.height,
|
||||
),
|
||||
topLeft = Offset(0f, size.height - bottomPaddingPx),
|
||||
size = Size(size.width, bottomPaddingPx),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -506,6 +506,7 @@
|
||||
<string name="grid_menu_import_wii_save">Import Wii Save</string>
|
||||
<string name="grid_menu_import_nand_backup">Import BootMii NAND Backup</string>
|
||||
<string name="grid_menu_online_system_update">Perform Online System Update</string>
|
||||
<string name="grid_menu_netplay">Netplay</string>
|
||||
<string name="grid_menu_load_wii_system_menu">Load Wii System Menu</string>
|
||||
<string name="grid_menu_load_wii_system_menu_installed">Load Wii System Menu (%s)</string>
|
||||
<string name="grid_menu_load_vwii_system_menu_installed">Load vWii System Menu (%s)</string>
|
||||
@@ -991,4 +992,59 @@ It can efficiently compress both junk data and encrypted Wii data.
|
||||
<string name="achievements_logout">Log Out</string>
|
||||
<string name="achievements_login_in_progress">Logging In</string>
|
||||
<string name="achievements_login_failed">Login Failed</string>
|
||||
|
||||
<!-- Netplay -->
|
||||
<string name="netplay_setup_title">Netplay Setup</string>
|
||||
<string name="netplay_connection_type">Connection type</string>
|
||||
<string name="netplay_connection_type_direct_connection">Direct connection</string>
|
||||
<string name="netplay_connection_type_traversal_server">Traversal server</string>
|
||||
<string name="netplay_connection_role_connect">Connect</string>
|
||||
<string name="netplay_connection_role_connect_loading">Connecting…</string>
|
||||
<string name="netplay_connection_role_host">Host</string>
|
||||
<string name="netplay_connection_role_host_loading">Starting…</string>
|
||||
<string name="netplay_nickname_label">Nickname</string>
|
||||
<string name="netplay_ip_address_label">IP address</string>
|
||||
<string name="netplay_host_code_label">Host code</string>
|
||||
<string name="netplay_port_label">Port</string>
|
||||
<string name="netplay_title">Netplay</string>
|
||||
<string name="netplay_start">Start</string>
|
||||
<string name="netplay_start_warning_title">Warning</string>
|
||||
<string name="netplay_start_warning_not_all_players_have_game">Not all players have the game. Do you really want to start?</string>
|
||||
<string name="netplay_chat_label">Chat</string>
|
||||
<string name="netplay_chat_send">Send</string>
|
||||
<string name="netplay_message_game_changed">Game changed to %1$s</string>
|
||||
<string name="netplay_message_buffer_changed">Buffer size changed to %1$d</string>
|
||||
<string name="netplay_message_host_input_authority_changed">"Host input authority %1$s"</string>
|
||||
<string name="netplay_message_desync">Possible desync detected: %1$s might have desynced at frame %2$d</string>
|
||||
<string name="netplay_game_label">Game</string>
|
||||
<string name="netplay_players_label">Players</string>
|
||||
<string name="netplay_players_name">Name</string>
|
||||
<string name="netplay_players_ping">Ping</string>
|
||||
<string name="netplay_players_mapping">Mapping</string>
|
||||
<string name="netplay_network_mode_label">Input mode</string>
|
||||
<string name="netplay_network_mode_fair_input_delay">Fair Input Delay</string>
|
||||
<string name="netplay_network_mode_host_input_authority">Host Input Authority</string>
|
||||
<string name="netplay_network_mode_golf">Golf Mode</string>
|
||||
<string name="netplay_buffer">Buffer</string>
|
||||
<string name="netplay_client_buffer">Max buffer</string>
|
||||
<string name="netplay_connection_lost">Netplay connection lost</string>
|
||||
<string name="netplay_save_transfer_progress_close">Close</string>
|
||||
<string name="netplay_game_digest_match">The hashes match</string>
|
||||
<string name="netplay_game_digest_mismatch">The hashes do not match</string>
|
||||
<string name="netplay_game_digest_close">Close</string>
|
||||
<string name="netplay_host_port_label">Port</string>
|
||||
<string name="netplay_use_upnp">Forward port (UPnP)</string>
|
||||
<string name="netplay_host_address_label">Join info</string>
|
||||
<string name="netplay_address_label">Address</string>
|
||||
<string name="netplay_code_label">Code</string>
|
||||
<string name="netplay_address_type_room_id">Room</string>
|
||||
<string name="netplay_address_type_external">External</string>
|
||||
<string name="netplay_address_type_local">Local</string>
|
||||
<string name="netplay_traversal_error_bad_host">Couldn\'t look up central server</string>
|
||||
<string name="netplay_traversal_error_version_too_old">Dolphin is too old for traversal server</string>
|
||||
<string name="netplay_address_loading">Loading…</string>
|
||||
<string name="netplay_address_unknown">Unknown</string>
|
||||
<string name="netplay_address_share">Share address</string>
|
||||
<string name="netplay_address_retry">Retry</string>
|
||||
<string name="netplay_transfer_progress">%1$s/%2$s MiB</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user