Browse Source

fix: persist paused image on resume, persist pause through lifecycle, add visual indicator of pause state, with resume on press

pull/3651/head
xXJSONDeruloXx 1 week ago
parent
commit
fd86f3009f
No known key found for this signature in database GPG Key ID: 629F3E618E280D7F
  1. 4
      src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
  2. 133
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
  3. 41
      src/android/app/src/main/jni/native.cpp
  4. 4
      src/android/app/src/main/res/drawable/circle_white.xml
  5. 20
      src/android/app/src/main/res/layout/fragment_emulation.xml

4
src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt

@ -152,6 +152,10 @@ object NativeLibrary {
external fun surfaceDestroyed() external fun surfaceDestroyed()
external fun getAppletCaptureBuffer(): ByteArray
external fun getAppletCaptureWidth(): Int
external fun getAppletCaptureHeight(): Int
/** /**
* Unpauses emulation from a paused state. * Unpauses emulation from a paused state.
*/ */

133
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt

@ -15,6 +15,7 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.BatteryManager import android.os.BatteryManager
import android.os.BatteryManager.* import android.os.BatteryManager.*
@ -97,6 +98,7 @@ import org.yuzu.yuzu_emu.utils.collect
import org.yuzu.yuzu_emu.utils.CustomSettingsHandler import org.yuzu.yuzu_emu.utils.CustomSettingsHandler
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.nio.ByteBuffer
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.or import kotlin.or
@ -141,6 +143,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
private var wasInputOverlayAutoHidden = false private var wasInputOverlayAutoHidden = false
private var overlayTouchActive = false private var overlayTouchActive = false
private var pausedFrameBitmap: Bitmap? = null
var shouldUseCustom = false var shouldUseCustom = false
private var isQuickSettingsMenuOpen = false private var isQuickSettingsMenuOpen = false
@ -703,6 +706,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
binding.inGameMenu.menu.findItem(R.id.menu_quick_settings)?.isVisible = binding.inGameMenu.menu.findItem(R.id.menu_quick_settings)?.isVisible =
BooleanSetting.ENABLE_QUICK_SETTINGS.getBoolean() BooleanSetting.ENABLE_QUICK_SETTINGS.getBoolean()
binding.pausedIcon.setOnClickListener {
if (this::emulationState.isInitialized && emulationState.isPaused) {
resumeEmulationFromUi()
}
}
binding.inGameMenu.menu.findItem(R.id.menu_lock_drawer).apply { binding.inGameMenu.menu.findItem(R.id.menu_lock_drawer).apply {
val lockMode = IntSetting.LOCK_DRAWER.getInt() val lockMode = IntSetting.LOCK_DRAWER.getInt()
val titleId = if (lockMode == DrawerLayout.LOCK_MODE_LOCKED_CLOSED) { val titleId = if (lockMode == DrawerLayout.LOCK_MODE_LOCKED_CLOSED) {
@ -728,11 +737,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
when (it.itemId) { when (it.itemId) {
R.id.menu_pause_emulation -> { R.id.menu_pause_emulation -> {
if (emulationState.isPaused) { if (emulationState.isPaused) {
emulationState.run(false, userInitiatedResume = true)
updatePauseMenuEntry(false)
resumeEmulationFromUi()
} else { } else {
emulationState.pause(userInitiated = true)
emulationState.pause()
updatePauseMenuEntry(true) updatePauseMenuEntry(true)
capturePausedFrameFromCore()
updatePausedFrameVisibility()
} }
binding.inGameMenu.requestFocus() binding.inGameMenu.requestFocus()
true true
@ -826,6 +836,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
} }
R.id.menu_exit -> { R.id.menu_exit -> {
clearPausedFrame()
emulationState.stop() emulationState.stop()
NativeConfig.reloadGlobalConfig() NativeConfig.reloadGlobalConfig()
emulationViewModel.setIsEmulationStopping(true) emulationViewModel.setIsEmulationStopping(true)
@ -1197,6 +1208,74 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
} }
} }
private fun capturePausedFrameFromCore() {
lifecycleScope.launch(Dispatchers.Default) {
val frameData = NativeLibrary.getAppletCaptureBuffer()
val width = NativeLibrary.getAppletCaptureWidth()
val height = NativeLibrary.getAppletCaptureHeight()
if (frameData.isEmpty() || width <= 0 || height <= 0) {
Log.warning(
"[EmulationFragment] Paused frame capture returned empty/invalid data. " +
"size=${frameData.size}, width=$width, height=$height"
)
return@launch
}
val expectedSize = width * height * 4
if (frameData.size < expectedSize) {
Log.warning(
"[EmulationFragment] Paused frame buffer smaller than expected. " +
"size=${frameData.size}, expected=$expectedSize"
)
return@launch
}
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(frameData, 0, expectedSize))
withContext(Dispatchers.Main) {
pausedFrameBitmap?.recycle()
pausedFrameBitmap = bitmap
updatePausedFrameVisibility()
}
}
}
private fun updatePausedFrameVisibility() {
val b = _binding ?: return
val shouldShowPaused = this::emulationState.isInitialized && emulationState.isPaused
b.pausedIcon.setVisible(shouldShowPaused)
if (!shouldShowPaused) {
b.pausedFrameImage.setVisible(false)
b.pausedFrameImage.setImageDrawable(null)
return
}
val bitmap = pausedFrameBitmap ?: run {
b.pausedFrameImage.setVisible(false)
b.pausedFrameImage.setImageDrawable(null)
return
}
b.pausedFrameImage.setImageBitmap(bitmap)
b.pausedFrameImage.setVisible(true)
}
private fun resumeEmulationFromUi() {
clearPausedFrame()
emulationState.run(false, explicitResume = true)
updatePauseMenuEntry(false)
updatePausedFrameVisibility()
}
private fun clearPausedFrame() {
val b = _binding
b?.pausedFrameImage?.setVisible(false)
b?.pausedFrameImage?.setImageDrawable(null)
pausedFrameBitmap?.recycle()
pausedFrameBitmap = null
}
private fun handleLoadAmiiboSelection(): Boolean { private fun handleLoadAmiiboSelection(): Boolean {
val binding = _binding ?: return true val binding = _binding ?: return true
@ -1292,7 +1371,9 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
if (emulationState.isRunning && emulationActivity?.isInPictureInPictureMode != true) { if (emulationState.isRunning && emulationActivity?.isInPictureInPictureMode != true) {
emulationState.pause() emulationState.pause()
updatePauseMenuEntry(true) updatePauseMenuEntry(true)
capturePausedFrameFromCore()
} }
updatePausedFrameVisibility()
} }
super.onPause() super.onPause()
} }
@ -1301,6 +1382,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
super.onDestroyView() super.onDestroyView()
amiiboLoadJob?.cancel() amiiboLoadJob?.cancel()
amiiboLoadJob = null amiiboLoadJob = null
clearPausedFrame()
_binding?.surfaceInputOverlay?.touchEventListener = null _binding?.surfaceInputOverlay?.touchEventListener = null
_binding = null _binding = null
isAmiiboPickerOpen = false isAmiiboPickerOpen = false
@ -1321,6 +1403,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
b.inGameMenu.post { b.inGameMenu.post {
if (!this::emulationState.isInitialized || _binding == null) return@post if (!this::emulationState.isInitialized || _binding == null) return@post
updatePauseMenuEntry(emulationState.isPaused) updatePauseMenuEntry(emulationState.isPaused)
updatePausedFrameVisibility()
} }
} }
@ -1760,6 +1843,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
// Only update surface reference, don't trigger state changes // Only update surface reference, don't trigger state changes
emulationState.updateSurfaceReference(holder.surface) emulationState.updateSurfaceReference(holder.surface)
} }
updatePausedFrameVisibility()
} }
override fun surfaceDestroyed(holder: SurfaceHolder) { override fun surfaceDestroyed(holder: SurfaceHolder) {
@ -2030,7 +2114,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
private val emulationCanStart: () -> Boolean private val emulationCanStart: () -> Boolean
) { ) {
private var state: State private var state: State
private var userPaused = false
private var surface: Surface? = null private var surface: Surface? = null
lateinit var emulationThread: Thread lateinit var emulationThread: Thread
@ -2062,7 +2145,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
} }
@Synchronized @Synchronized
fun pause(userInitiated: Boolean = false) {
fun pause() {
if (state != State.PAUSED) { if (state != State.PAUSED) {
Log.debug("[EmulationFragment] Pausing emulation.") Log.debug("[EmulationFragment] Pausing emulation.")
@ -2070,11 +2153,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
NativeLibrary.playTimeManagerStop() NativeLibrary.playTimeManagerStop()
state = State.PAUSED state = State.PAUSED
userPaused = userInitiated
} else { } else {
if (userInitiated) {
userPaused = true
}
Log.warning("[EmulationFragment] Pause called while already paused.") Log.warning("[EmulationFragment] Pause called while already paused.")
} }
} }
@ -2083,16 +2162,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
fun run( fun run(
isActivityRecreated: Boolean, isActivityRecreated: Boolean,
programIndex: Int = 0, programIndex: Int = 0,
userInitiatedResume: Boolean = false
explicitResume: Boolean = false
) { ) {
if (userInitiatedResume) {
userPaused = false
}
if (isActivityRecreated) { if (isActivityRecreated) {
if (NativeLibrary.isRunning()) { if (NativeLibrary.isRunning()) {
state = State.PAUSED state = State.PAUSED
userPaused = false
} }
} else { } else {
Log.debug("[EmulationFragment] activity resumed or fresh start") Log.debug("[EmulationFragment] activity resumed or fresh start")
@ -2100,7 +2174,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
// If the surface is set, run now. Otherwise, wait for it to get set. // If the surface is set, run now. Otherwise, wait for it to get set.
if (surface != null) { if (surface != null) {
runWithValidSurface(programIndex)
runWithValidSurface(programIndex, explicitResume)
} }
} }
@ -2125,7 +2199,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
@Synchronized @Synchronized
fun updateSurface() { fun updateSurface() {
if (surface != null) {
if (surface != null && state == State.RUNNING) {
NativeLibrary.surfaceChanged(surface) NativeLibrary.surfaceChanged(surface)
} }
} }
@ -2141,35 +2215,40 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
@Synchronized @Synchronized
fun clearSurface() { fun clearSurface() {
if (surface == null) { if (surface == null) {
Log.warning("[EmulationFragment] clearSurface called, but surface already null.")
Log.debug("[EmulationFragment] clearSurface called, but surface already null.")
} else { } else {
NativeLibrary.surfaceDestroyed()
surface = null surface = null
Log.debug("[EmulationFragment] Surface destroyed.") Log.debug("[EmulationFragment] Surface destroyed.")
when (state) { when (state) {
State.RUNNING -> { State.RUNNING -> {
state = State.PAUSED state = State.PAUSED
userPaused = false
} }
State.PAUSED -> Log.warning(
State.PAUSED -> Log.debug(
"[EmulationFragment] Surface cleared while emulation paused." "[EmulationFragment] Surface cleared while emulation paused."
) )
else -> Log.warning(
else -> Log.debug(
"[EmulationFragment] Surface cleared while emulation stopped." "[EmulationFragment] Surface cleared while emulation stopped."
) )
} }
} }
} }
private fun runWithValidSurface(programIndex: Int = 0) {
NativeLibrary.surfaceChanged(surface)
private fun runWithValidSurface(programIndex: Int = 0, explicitResume: Boolean = false) {
if (!emulationCanStart.invoke()) { if (!emulationCanStart.invoke()) {
return return
} }
val currentSurface = surface
if (currentSurface == null || !currentSurface.isValid) {
Log.debug("[EmulationFragment] runWithValidSurface called with invalid surface.")
return
}
when (state) { when (state) {
State.STOPPED -> { State.STOPPED -> {
NativeLibrary.surfaceChanged(currentSurface)
emulationThread = Thread({ emulationThread = Thread({
Log.debug("[EmulationFragment] Starting emulation thread.") Log.debug("[EmulationFragment] Starting emulation thread.")
NativeLibrary.run(gamePath, programIndex, true) NativeLibrary.run(gamePath, programIndex, true)
@ -2178,11 +2257,15 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
} }
State.PAUSED -> { State.PAUSED -> {
if (userPaused) {
Log.debug("[EmulationFragment] Surface restored while user paused.")
if (!explicitResume) {
Log.debug(
"[EmulationFragment] Surface restored while emulation paused; " +
"deferring native surface update until explicit resume."
)
return return
} }
NativeLibrary.surfaceChanged(currentSurface)
Log.debug("[EmulationFragment] Resuming emulation.") Log.debug("[EmulationFragment] Resuming emulation.")
NativeLibrary.unpauseEmulation() NativeLibrary.unpauseEmulation()
NativeLibrary.playTimeManagerStart() NativeLibrary.playTimeManagerStart()

41
src/android/app/src/main/jni/native.cpp

@ -89,6 +89,8 @@
#include "jni/native.h" #include "jni/native.h"
#include "video_core/renderer_base.h" #include "video_core/renderer_base.h"
#include "video_core/renderer_vulkan/renderer_vulkan.h" #include "video_core/renderer_vulkan/renderer_vulkan.h"
#include "video_core/capture.h"
#include "video_core/textures/decoders.h"
#include "video_core/vulkan_common/vulkan_instance.h" #include "video_core/vulkan_common/vulkan_instance.h"
#include "video_core/vulkan_common/vulkan_surface.h" #include "video_core/vulkan_common/vulkan_surface.h"
#include "video_core/shader_notify.h" #include "video_core/shader_notify.h"
@ -780,9 +782,10 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_surfaceChanged(JNIEnv* env, jobject i
} }
void Java_org_yuzu_yuzu_1emu_NativeLibrary_surfaceDestroyed(JNIEnv* env, jobject instance) { void Java_org_yuzu_yuzu_1emu_NativeLibrary_surfaceDestroyed(JNIEnv* env, jobject instance) {
ANativeWindow_release(EmulationSession::GetInstance().NativeWindow());
if (auto* native_window = EmulationSession::GetInstance().NativeWindow(); native_window) {
ANativeWindow_release(native_window);
}
EmulationSession::GetInstance().SetNativeWindow(nullptr); EmulationSession::GetInstance().SetNativeWindow(nullptr);
EmulationSession::GetInstance().SurfaceChanged();
} }
void Java_org_yuzu_yuzu_1emu_NativeLibrary_setAppDirectory(JNIEnv* env, jobject instance, void Java_org_yuzu_yuzu_1emu_NativeLibrary_setAppDirectory(JNIEnv* env, jobject instance,
@ -969,6 +972,40 @@ jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isPaused(JNIEnv* env, jclass claz
return static_cast<jboolean>(EmulationSession::GetInstance().IsPaused()); return static_cast<jboolean>(EmulationSession::GetInstance().IsPaused());
} }
jbyteArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletCaptureBuffer(JNIEnv* env, jclass clazz) {
using namespace VideoCore::Capture;
if (!EmulationSession::GetInstance().IsRunning()) {
return env->NewByteArray(0);
}
const auto tiled = EmulationSession::GetInstance().System().GPU().GetAppletCaptureBuffer();
if (tiled.size() < TiledSize) {
return env->NewByteArray(0);
}
std::vector<u8> linear(LinearWidth * LinearHeight * BytesPerPixel);
Tegra::Texture::UnswizzleTexture(linear, tiled, BytesPerPixel, LinearWidth, LinearHeight,
LinearDepth, BlockHeight, BlockDepth);
auto buffer = env->NewByteArray(static_cast<jsize>(linear.size()));
if (!buffer) {
return env->NewByteArray(0);
}
env->SetByteArrayRegion(buffer, 0, static_cast<jsize>(linear.size()),
reinterpret_cast<const jbyte*>(linear.data()));
return buffer;
}
jint Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletCaptureWidth(JNIEnv* env, jclass clazz) {
return static_cast<jint>(VideoCore::Capture::LinearWidth);
}
jint Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletCaptureHeight(JNIEnv* env, jclass clazz) {
return static_cast<jint>(VideoCore::Capture::LinearHeight);
}
void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeSystem(JNIEnv* env, jclass clazz, void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeSystem(JNIEnv* env, jclass clazz,
jboolean reload) { jboolean reload) {
// Initialize the emulated system. // Initialize the emulated system.

4
src/android/app/src/main/res/drawable/circle_white.xml

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#E6FFFFFF" />
</shape>

20
src/android/app/src/main/res/layout/fragment_emulation.xml

@ -142,6 +142,26 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:fitsSystemWindows="false"> android:fitsSystemWindows="false">
<ImageView
android:id="@+id/paused_frame_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:visibility="gone" />
<ImageView
android:id="@+id/paused_icon"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_gravity="center"
android:background="@drawable/circle_white"
android:contentDescription="@string/emulation_unpause"
android:padding="14dp"
android:src="@drawable/ic_pause"
android:visibility="gone"
app:tint="@android:color/black" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/show_stats_overlay_text" android:id="@+id/show_stats_overlay_text"
style="@style/TextAppearance.Material3.BodySmall" style="@style/TextAppearance.Material3.BodySmall"

Loading…
Cancel
Save