Show save transfer progress

When transferring saves from the host. Equivalent of ChunkedProgressDialog in QT.
This commit is contained in:
Tom Pratt 2026-04-25 17:39:41 +02:00
parent ab6c2d0d56
commit 3572afcbbf
9 changed files with 227 additions and 15 deletions

View File

@ -13,7 +13,9 @@ 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
@ -26,6 +28,7 @@ import kotlinx.coroutines.withContext
import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType
import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage
import org.dolphinemu.dolphinemu.features.netplay.model.Player
import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress
object Netplay {
@Keep
@ -87,6 +90,9 @@ object Netplay {
)
val padBuffer = _padBuffer.asSharedFlow()
private val _saveTransferProgress = MutableStateFlow<SaveTransferProgress?>(null)
val saveTransferProgress = _saveTransferProgress.asStateFlow()
suspend fun join(): Boolean = withContext(Dispatchers.IO) {
val scope = createSessionScope()
@ -140,6 +146,7 @@ object Netplay {
_game.resetReplayCache()
_hostInputAuthorityEnabled.resetReplayCache()
_padBuffer.resetReplayCache()
_saveTransferProgress.value = null
}
private fun createSessionScope(): CoroutineScope {
@ -234,6 +241,44 @@ object Netplay {
_padBuffer.tryEmit(buffer)
}
@Keep
@JvmStatic
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
@JvmStatic
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
@JvmStatic
fun onHideChunkedProgressDialog() {
_saveTransferProgress.value = null
}
// Settings
object Settings {
@JvmStatic

View File

@ -2,8 +2,6 @@
package org.dolphinemu.dolphinemu.features.netplay.model
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.DelicateCoroutinesApi
@ -42,6 +40,8 @@ class NetplayViewModel : ViewModel() {
private val _maxBuffer = MutableStateFlow(Netplay.Settings.getClientBufferSize())
val maxBuffer = _maxBuffer.asStateFlow()
val saveTransferProgress = Netplay.saveTransferProgress
init {
if (!Netplay.isClientConnected()) {
_goBack.trySend(Unit)

View File

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

View File

@ -52,6 +52,7 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider {
hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value,
maxBuffer = viewModel.maxBuffer.collectAsState().value,
onMaxBufferChanged = viewModel::setMaxBuffer,
saveTransferProgress = viewModel.saveTransferProgress.collectAsState().value,
)
}
}

View File

@ -34,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
@ -70,10 +71,12 @@ import kotlinx.coroutines.flow.emptyFlow
import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage
import org.dolphinemu.dolphinemu.features.netplay.model.Player
import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress
import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme
import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer
import org.dolphinemu.dolphinemu.ui.theme.OutlinedBox
import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -87,6 +90,7 @@ fun NetplayScreen(
maxBuffer: Int,
onMaxBufferChanged: (Int) -> Unit,
players: List<Player>,
saveTransferProgress: SaveTransferProgress?,
) {
Scaffold(
topBar = {
@ -136,16 +140,31 @@ fun NetplayScreen(
LaunchedEffect(Unit) {
connectionLost.collect { showConnectionLostDialog = true }
}
if (showConnectionLostDialog) {
AlertDialog(
text = { Text(stringResource(R.string.netplay_connection_lost)) },
confirmButton = {
TextButton(onClick = onBackClicked) {
Text(stringResource(R.string.ok))
}
},
onDismissRequest = onBackClicked,
)
var dismissSaveTransferProgressDialog by remember { mutableStateOf(false) }
if (saveTransferProgress == null) {
dismissSaveTransferProgressDialog = false
}
when {
showConnectionLostDialog -> {
AlertDialog(
text = { Text(stringResource(R.string.netplay_connection_lost)) },
confirmButton = {
TextButton(onClick = onBackClicked) {
Text(stringResource(R.string.ok))
}
},
onDismissRequest = onBackClicked,
)
}
saveTransferProgress != null && !dismissSaveTransferProgressDialog -> {
SaveTransferProgressDialog(
saveTransferProgress = saveTransferProgress,
onDismiss = { dismissSaveTransferProgressDialog = true },
)
}
}
}
}
@ -519,6 +538,68 @@ private fun BufferInput(
}
}
@Composable
private fun SaveTransferProgressDialog(
saveTransferProgress: SaveTransferProgress,
onDismiss: () -> Unit,
) {
AlertDialog(
title = { Text(saveTransferProgress.title) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
saveTransferProgress.playerProgresses.forEachIndexed { index, playerProgress ->
SaveTransferProgressRow(
playerProgress = playerProgress,
totalSize = saveTransferProgress.totalSize,
)
if (index < saveTransferProgress.playerProgresses.lastIndex) {
HorizontalDivider()
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.netplay_save_transfer_progress_close))
}
},
onDismissRequest = onDismiss,
)
}
@Composable
private fun SaveTransferProgressRow(
playerProgress: SaveTransferProgress.PlayerProgress,
totalSize: Long,
) {
fun formatMib(bytes: Long) = String.format(Locale.US, "%.2f", bytes / 1024f / 1024f)
val progressFraction = (playerProgress.progress.toFloat() / totalSize).coerceIn(0f, 1f)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
LinearProgressIndicator(
progress = { progressFraction },
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = playerProgress.name,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = "${formatMib(playerProgress.progress)}/${formatMib(totalSize)} MiB",
)
}
}
}
@Preview
@Composable
private fun NetplayScreenPreview() {
@ -589,5 +670,22 @@ private fun PreviewNetplayScreen() {
hostInputAuthorityEnabled = true,
maxBuffer = 10,
onMaxBufferChanged = {},
saveTransferProgress = null,
// saveTransferProgress = SaveTransferProgress(
// title = "Title",
// totalSize = 1024L,
// playerProgresses = listOf(
// SaveTransferProgress.PlayerProgress(
// playerId = 1,
// name = "Player 1",
// progress = 256,
// ),
// SaveTransferProgress.PlayerProgress(
// playerId = 2,
// name = "Player 2",
// progress = 512,
// ),
// ),
// ),
)
}

View File

@ -1009,4 +1009,5 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="netplay_players_mapping">Mapping</string>
<string name="netplay_max_buffer">Max buffer</string>
<string name="netplay_connection_lost">Netplay connection lost</string>
<string name="netplay_save_transfer_progress_close">Close</string>
</resources>

View File

@ -40,6 +40,9 @@ 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 jclass s_netplay_player_class;
static jmethodID s_netplay_player_constructor;
@ -309,6 +312,21 @@ 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;
}
jclass GetNetplayPlayerClass()
{
return s_netplay_player_class;
@ -741,6 +759,12 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved)
s_netplay_on_chat_message_received =
env->GetStaticMethodID(netplay_class, "onChatMessageReceived", "(Ljava/lang/String;)V");
s_netplay_update = env->GetStaticMethodID(netplay_class, "onUpdate", "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V");
s_netplay_on_show_chunked_progress_dialog =
env->GetStaticMethodID(netplay_class, "onShowChunkedProgressDialog", "(Ljava/lang/String;J[I)V");
s_netplay_on_set_chunked_progress =
env->GetStaticMethodID(netplay_class, "onSetChunkedProgress", "(IJ)V");
s_netplay_on_hide_chunked_progress_dialog =
env->GetStaticMethodID(netplay_class, "onHideChunkedProgressDialog", "()V");
env->DeleteLocalRef(netplay_class);
const jclass netplay_player_class =

View File

@ -43,6 +43,9 @@ jmethodID GetNetplayOnHostInputAuthorityChanged();
jmethodID GetNetplayOnPadBufferChanged();
jmethodID GetNetplayOnChatMessageReceived();
jmethodID GetNetplayUpdate();
jmethodID GetNetplayOnShowChunkedProgressDialog();
jmethodID GetNetplayOnSetChunkedProgress();
jmethodID GetNetplayOnHideChunkedProgressDialog();
jclass GetNetplayPlayerClass();
jmethodID GetNetplayPlayerConstructor();

View File

@ -186,9 +186,36 @@ void NetPlayUICallbacks::ShowGameDigestDialog(const std::string&) {}
void NetPlayUICallbacks::SetGameDigestProgress(int, int) {}
void NetPlayUICallbacks::SetGameDigestResult(int, const std::string&) {}
void NetPlayUICallbacks::AbortGameDigest() {}
void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string&, u64, std::span<const int>) {}
void NetPlayUICallbacks::HideChunkedProgressDialog() {}
void NetPlayUICallbacks::SetChunkedProgress(int, u64) {}
void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string& title, u64 data_size,
std::span<const int> players)
{
JNIEnv* env = IDCache::GetEnvForThread();
jintArray j_players = env->NewIntArray(static_cast<jsize>(players.size()));
env->SetIntArrayRegion(j_players, 0, static_cast<jsize>(players.size()), players.data());
env->CallStaticVoidMethod(IDCache::GetNetplayClass(),
IDCache::GetNetplayOnShowChunkedProgressDialog(),
ToJString(env, title), static_cast<jlong>(data_size), j_players);
env->DeleteLocalRef(j_players);
}
void NetPlayUICallbacks::HideChunkedProgressDialog()
{
JNIEnv* env = IDCache::GetEnvForThread();
env->CallStaticVoidMethod(IDCache::GetNetplayClass(),
IDCache::GetNetplayOnHideChunkedProgressDialog());
}
void NetPlayUICallbacks::SetChunkedProgress(int pid, u64 progress)
{
JNIEnv* env = IDCache::GetEnvForThread();
env->CallStaticVoidMethod(IDCache::GetNetplayClass(),
IDCache::GetNetplayOnSetChunkedProgress(),
static_cast<jint>(pid), static_cast<jlong>(progress));
}
void NetPlayUICallbacks::SetHostWiiSyncData(std::vector<u64>, std::string) {}
} // namespace NetPlay