mirror of
https://github.com/rafaelvcaetano/melonDS-android.git
synced 2025-02-18 20:58:13 +00:00
Implement rewind screen and logic
Currently, rewind is always enabled and with hardcoded values. Adding settings for these configurations is still required.
This commit is contained in:
parent
72371baa00
commit
39d2fa43a1
@ -74,6 +74,7 @@ android {
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
configurations.all {
|
||||
resolutionStrategy.eachDependency {
|
||||
@ -90,6 +91,10 @@ android {
|
||||
dependencies {
|
||||
val gitHubImplementation by configurations
|
||||
|
||||
with(Dependencies.Tools) {
|
||||
coreLibraryDesugaring(desugarJdkLibs)
|
||||
}
|
||||
|
||||
with(Dependencies.Kotlin) {
|
||||
implementation(kotlinStdlib)
|
||||
}
|
||||
|
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
@ -31,6 +31,8 @@
|
||||
-keep class me.magnum.melonds.domain.model.ConsoleType { *; }
|
||||
-keep class me.magnum.melonds.domain.model.MicSource { *; }
|
||||
-keep class me.magnum.melonds.domain.model.Cheat { *; }
|
||||
-keep class me.magnum.melonds.ui.emulator.rewind.model.RewindSaveState { *; }
|
||||
-keep class me.magnum.melonds.ui.emulator.rewind.model.RewindWindow { *; }
|
||||
-keep class me.magnum.melonds.ui.settings.fragments.**
|
||||
-keep class me.magnum.melonds.common.UriFileHandler {
|
||||
public int open(java.lang.String, java.lang.String);
|
||||
|
@ -267,6 +267,79 @@ Java_me_magnum_melonds_MelonEmulator_loadStateInternal(JNIEnv* env, jobject thiz
|
||||
return MelonDSAndroid::loadState(saveStatePath);
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_me_magnum_melonds_MelonEmulator_loadRewindState(JNIEnv* env, jobject thiz, jobject rewindSaveState) {
|
||||
bool result = true;
|
||||
|
||||
pthread_mutex_lock(&emuThreadMutex);
|
||||
if (!stop) {
|
||||
bool wasPaused = paused;
|
||||
if (paused) {
|
||||
pthread_mutex_unlock(&emuThreadMutex);
|
||||
} else {
|
||||
pthread_mutex_unlock(&emuThreadMutex);
|
||||
Java_me_magnum_melonds_MelonEmulator_pauseEmulation(env, thiz);
|
||||
}
|
||||
|
||||
jclass rewindSaveStateClass = env->FindClass("me/magnum/melonds/ui/emulator/rewind/model/RewindSaveState");
|
||||
jfieldID bufferField = env->GetFieldID(rewindSaveStateClass, "buffer", "Ljava/nio/ByteBuffer;");
|
||||
jfieldID screenshotBufferField = env->GetFieldID(rewindSaveStateClass, "screenshotBuffer", "Ljava/nio/ByteBuffer;");
|
||||
jfieldID frameField = env->GetFieldID(rewindSaveStateClass, "frame", "I");
|
||||
jobject buffer = env->GetObjectField(rewindSaveState, bufferField);
|
||||
jobject screenshotBuffer = env->GetObjectField(rewindSaveState, screenshotBufferField);
|
||||
jint frame = (int) env->GetIntField(rewindSaveState, frameField);
|
||||
|
||||
// Make sure that the thread is really paused to avoid data corruption
|
||||
while (!isThreadReallyPaused);
|
||||
|
||||
RewindManager::RewindSaveState state = RewindManager::RewindSaveState {
|
||||
.buffer = (u8*) env->GetDirectBufferAddress(buffer),
|
||||
.bufferSize = (u32) env->GetDirectBufferCapacity(buffer),
|
||||
.screenshot = (u8*) env->GetDirectBufferAddress(screenshotBuffer),
|
||||
.screenshotSize = (u32) env->GetDirectBufferCapacity(screenshotBuffer),
|
||||
.frame = frame
|
||||
};
|
||||
|
||||
result = MelonDSAndroid::loadRewindState(state);
|
||||
|
||||
// Resume emulation if it was running
|
||||
if (!wasPaused) {
|
||||
Java_me_magnum_melonds_MelonEmulator_resumeEmulation(env, thiz);
|
||||
}
|
||||
} else {
|
||||
// If the emulation is stopping, just ignore it
|
||||
pthread_mutex_unlock(&emuThreadMutex);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_me_magnum_melonds_MelonEmulator_getRewindWindow(JNIEnv* env, jobject thiz) {
|
||||
auto currentRewindWindow = MelonDSAndroid::getRewindWindow();
|
||||
|
||||
jclass rewindSaveStateClass = env->FindClass("me/magnum/melonds/ui/emulator/rewind/model/RewindSaveState");
|
||||
jmethodID rewindSaveStateConstructor = env->GetMethodID(rewindSaveStateClass, "<init>", "(Ljava/nio/ByteBuffer;Ljava/nio/ByteBuffer;I)V");
|
||||
|
||||
jclass listClass = env->FindClass("java/util/ArrayList");
|
||||
jmethodID listConstructor = env->GetMethodID(listClass, "<init>", "()V");
|
||||
jmethodID listAddMethod = env->GetMethodID(listClass, "add", "(ILjava/lang/Object;)V");
|
||||
jobject rewindStateList = env->NewObject(listClass, listConstructor);
|
||||
|
||||
int index = 0;
|
||||
for (auto state : currentRewindWindow.rewindStates) {
|
||||
jobject stateBuffer = env->NewDirectByteBuffer(state.buffer, state.bufferSize);
|
||||
jobject stateScreenshot = env->NewDirectByteBuffer(state.screenshot, state.screenshotSize);
|
||||
jobject rewindSaveState = env->NewObject(rewindSaveStateClass, rewindSaveStateConstructor, stateBuffer, stateScreenshot, state.frame);
|
||||
env->CallVoidMethod(rewindStateList, listAddMethod, index++, rewindSaveState);
|
||||
}
|
||||
|
||||
jclass rewindWindowClass = env->FindClass("me/magnum/melonds/ui/emulator/rewind/model/RewindWindow");
|
||||
jmethodID rewindWindowConstructor = env->GetMethodID(rewindWindowClass, "<init>", "(ILjava/util/ArrayList;)V");
|
||||
jobject rewindWindow = env->NewObject(rewindWindowClass, rewindWindowConstructor, currentRewindWindow.currentFrame, rewindStateList);
|
||||
return rewindWindow;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_me_magnum_melonds_MelonEmulator_stopEmulation(JNIEnv* env, jobject thiz)
|
||||
{
|
||||
@ -414,6 +487,9 @@ MelonDSAndroid::EmulatorConfiguration buildEmulatorConfiguration(JNIEnv* env, jo
|
||||
finalEmulatorConfiguration.micSource = micSource;
|
||||
finalEmulatorConfiguration.firmwareConfiguration = buildFirmwareConfiguration(env, firmwareConfigurationObject);
|
||||
finalEmulatorConfiguration.renderSettings = buildRenderSettings(env, rendererConfigurationObject);
|
||||
finalEmulatorConfiguration.rewindEnabled = 1;
|
||||
finalEmulatorConfiguration.rewindCaptureSpacingSeconds = 2;
|
||||
finalEmulatorConfiguration.rewindLengthSeconds = 30;
|
||||
return finalEmulatorConfiguration;
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,8 @@ import me.magnum.melonds.domain.model.Cheat
|
||||
import me.magnum.melonds.domain.model.EmulatorConfiguration
|
||||
import me.magnum.melonds.domain.model.Input
|
||||
import me.magnum.melonds.common.UriFileHandler
|
||||
import me.magnum.melonds.ui.emulator.rewind.model.RewindSaveState
|
||||
import me.magnum.melonds.ui.emulator.rewind.model.RewindWindow
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object MelonEmulator {
|
||||
@ -81,6 +83,10 @@ object MelonEmulator {
|
||||
|
||||
private external fun loadStateInternal(path: String): Boolean
|
||||
|
||||
external fun loadRewindState(rewindSaveState: RewindSaveState): Boolean
|
||||
|
||||
external fun getRewindWindow(): RewindWindow
|
||||
|
||||
external fun onScreenTouch(x: Int, y: Int)
|
||||
|
||||
external fun onScreenRelease()
|
||||
|
@ -17,8 +17,10 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.*
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.squareup.picasso.Picasso
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.reactivex.Single
|
||||
@ -42,6 +44,9 @@ import me.magnum.melonds.ui.cheats.CheatsActivity
|
||||
import me.magnum.melonds.ui.emulator.DSRenderer.RendererListener
|
||||
import me.magnum.melonds.ui.emulator.firmware.FirmwareEmulatorDelegate
|
||||
import me.magnum.melonds.ui.emulator.input.*
|
||||
import me.magnum.melonds.ui.emulator.rewind.EdgeSpacingDecorator
|
||||
import me.magnum.melonds.ui.emulator.rewind.model.RewindSaveState
|
||||
import me.magnum.melonds.ui.emulator.rewind.RewindSaveStateAdapter
|
||||
import me.magnum.melonds.ui.emulator.rom.RomEmulatorDelegate
|
||||
import me.magnum.melonds.ui.settings.SettingsActivity
|
||||
import java.net.URLEncoder
|
||||
@ -160,6 +165,10 @@ class EmulatorActivity : AppCompatActivity(), RendererListener {
|
||||
microphonePermissionSubject.onNext(it)
|
||||
}
|
||||
|
||||
private val rewindSaveStateAdapter = RewindSaveStateAdapter {
|
||||
MelonEmulator.loadRewindState(it)
|
||||
closeRewindWindow()
|
||||
}
|
||||
private val microphonePermissionSubject = PublishSubject.create<Boolean>()
|
||||
private var emulatorSetupDisposable: Disposable? = null
|
||||
private var cheatsClosedListener: (() -> Unit)? = null
|
||||
@ -189,6 +198,15 @@ class EmulatorActivity : AppCompatActivity(), RendererListener {
|
||||
|
||||
binding.textFps.visibility = View.INVISIBLE
|
||||
binding.viewLayoutControls.setLayoutComponentViewBuilderFactory(RuntimeLayoutComponentViewBuilderFactory())
|
||||
binding.layoutRewind.setOnClickListener {
|
||||
closeRewindWindow()
|
||||
}
|
||||
binding.listRewind.apply {
|
||||
val listLayoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, true)
|
||||
layoutManager = listLayoutManager
|
||||
addItemDecoration(EdgeSpacingDecorator())
|
||||
adapter = rewindSaveStateAdapter
|
||||
}
|
||||
|
||||
viewModel.getBackground().observe(this) {
|
||||
dsRenderer.setBackground(it)
|
||||
@ -242,8 +260,9 @@ class EmulatorActivity : AppCompatActivity(), RendererListener {
|
||||
super.onResume()
|
||||
binding.surfaceMain.onResume()
|
||||
|
||||
if (emulatorReady && !emulatorPaused)
|
||||
if (emulatorReady && !emulatorPaused) {
|
||||
MelonEmulator.resumeEmulation()
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchEmulator() {
|
||||
@ -307,31 +326,33 @@ class EmulatorActivity : AppCompatActivity(), RendererListener {
|
||||
binding.textFps.isGone = true
|
||||
} else {
|
||||
binding.textFps.isVisible = true
|
||||
val newParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT)
|
||||
val newParams = ConstraintLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT)
|
||||
when (fpsCounterPosition) {
|
||||
FpsCounterPosition.TOP_LEFT -> {
|
||||
newParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT)
|
||||
newParams.addRule(RelativeLayout.ALIGN_PARENT_TOP)
|
||||
newParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
newParams.leftToLeft = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
}
|
||||
FpsCounterPosition.TOP_CENTER -> {
|
||||
newParams.addRule(RelativeLayout.CENTER_HORIZONTAL)
|
||||
newParams.addRule(RelativeLayout.ALIGN_PARENT_TOP)
|
||||
newParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
newParams.leftToLeft = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
newParams.rightToRight = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
}
|
||||
FpsCounterPosition.TOP_RIGHT -> {
|
||||
newParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT)
|
||||
newParams.addRule(RelativeLayout.ALIGN_PARENT_TOP)
|
||||
newParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
newParams.rightToRight = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
}
|
||||
FpsCounterPosition.BOTTOM_LEFT -> {
|
||||
newParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT)
|
||||
newParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM)
|
||||
newParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
newParams.leftToLeft = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
}
|
||||
FpsCounterPosition.BOTTOM_CENTER -> {
|
||||
newParams.addRule(RelativeLayout.CENTER_HORIZONTAL)
|
||||
newParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM)
|
||||
newParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
newParams.leftToLeft = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
newParams.rightToRight = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
}
|
||||
FpsCounterPosition.BOTTOM_RIGHT -> {
|
||||
newParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT)
|
||||
newParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM)
|
||||
newParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
newParams.rightToRight = ConstraintLayout.LayoutParams.PARENT_ID
|
||||
}
|
||||
}
|
||||
binding.textFps.layoutParams = newParams
|
||||
@ -443,7 +464,11 @@ class EmulatorActivity : AppCompatActivity(), RendererListener {
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (emulatorReady) {
|
||||
this.pauseEmulation()
|
||||
if (isRewindWindowOpen()) {
|
||||
closeRewindWindow()
|
||||
} else {
|
||||
this.pauseEmulation()
|
||||
}
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
@ -490,7 +515,7 @@ class EmulatorActivity : AppCompatActivity(), RendererListener {
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (nativeInputListener.onKeyEvent(event))
|
||||
if (!isRewindWindowOpen() && nativeInputListener.onKeyEvent(event))
|
||||
return true
|
||||
|
||||
return super.dispatchKeyEvent(event)
|
||||
@ -520,6 +545,27 @@ class EmulatorActivity : AppCompatActivity(), RendererListener {
|
||||
cheatsLauncher.launch(intent)
|
||||
}
|
||||
|
||||
private fun isRewindWindowOpen(): Boolean {
|
||||
return binding.root.currentState == R.id.rewind_visible
|
||||
}
|
||||
|
||||
fun openRewindWindow() {
|
||||
binding.root.transitionToState(R.id.rewind_visible)
|
||||
val rewindWindow = MelonEmulator.getRewindWindow()
|
||||
rewindSaveStateAdapter.setRewindWindow(rewindWindow)
|
||||
}
|
||||
|
||||
fun closeRewindWindow() {
|
||||
binding.root.transitionToState(R.id.rewind_hidden)
|
||||
MelonEmulator.resumeEmulation()
|
||||
}
|
||||
|
||||
fun rewindToState(state: RewindSaveState) {
|
||||
if (!MelonEmulator.loadRewindState(state)) {
|
||||
Toast.makeText(this@EmulatorActivity, "Failed to rewind", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a [Single] that emits the emulator's configuration taking into account permissions that have not been granted. If the provided base configuration requires the
|
||||
* use of certain permissions and [requestPermissions] is true, they will be requested to the user before returning the final configuration.
|
||||
@ -602,8 +648,9 @@ class EmulatorActivity : AppCompatActivity(), RendererListener {
|
||||
super.onPause()
|
||||
binding.surfaceMain.onPause()
|
||||
|
||||
if (emulatorReady && !emulatorPaused)
|
||||
if (emulatorReady && !emulatorPaused) {
|
||||
MelonEmulator.pauseEmulation()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
@ -0,0 +1,38 @@
|
||||
package me.magnum.melonds.ui.emulator.rewind
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.core.view.marginLeft
|
||||
import androidx.core.view.marginRight
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* [RecyclerView.ItemDecoration] that adds spacing to the edge views so that these items always appear centered in the view. Only works for horizontal lists.
|
||||
*/
|
||||
class EdgeSpacingDecorator : RecyclerView.ItemDecoration() {
|
||||
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
super.getItemOffsets(outRect, view, parent, state)
|
||||
|
||||
val position = parent.getChildAdapterPosition(view)
|
||||
if (position == 0) {
|
||||
val offset = getEdgeViewOffset(view, parent)
|
||||
outRect.right = offset
|
||||
} else if (position == state.itemCount - 1) {
|
||||
val offset = getEdgeViewOffset(view, parent)
|
||||
outRect.left = offset
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEdgeViewOffset(view: View, parent: RecyclerView): Int {
|
||||
val viewWidth = if (view.width == 0) {
|
||||
// View size still unknown. Measure it
|
||||
view.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
|
||||
view.measuredWidth
|
||||
} else {
|
||||
view.width
|
||||
}
|
||||
|
||||
return (parent.width - viewWidth - view.marginLeft - view.marginRight) / 2
|
||||
}
|
||||
}
|
@ -0,0 +1,97 @@
|
||||
package me.magnum.melonds.ui.emulator.rewind
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.marginLeft
|
||||
import androidx.core.view.marginRight
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import me.magnum.melonds.R
|
||||
import me.magnum.melonds.databinding.ItemRewindSaveStateBinding
|
||||
import me.magnum.melonds.ui.emulator.rewind.model.RewindSaveState
|
||||
import me.magnum.melonds.ui.emulator.rewind.model.RewindWindow
|
||||
import java.text.DecimalFormat
|
||||
import java.time.Duration
|
||||
|
||||
class RewindSaveStateAdapter(private val onRewindSaveStateSelected: (RewindSaveState) -> Unit) : RecyclerView.Adapter<RewindSaveStateAdapter.RewindSaveStateViewHolder>() {
|
||||
|
||||
class RewindSaveStateViewHolder(private val context: Context, private val binding: ItemRewindSaveStateBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
companion object {
|
||||
private val SECONDS_FORMATTER = DecimalFormat("#0.##")
|
||||
}
|
||||
|
||||
private lateinit var state: RewindSaveState
|
||||
|
||||
fun setRewindSaveState(state: RewindSaveState, window: RewindWindow) {
|
||||
val screenshotDrawable = BitmapDrawable(context.resources, state.screenshot)
|
||||
val durationToState = window.getDeltaFromEmulationTimeToRewindState(state)
|
||||
|
||||
binding.imageScreenshot.setImageDrawable(screenshotDrawable)
|
||||
binding.textTimestamp.text = getDurationString(context, durationToState)
|
||||
this.state = state
|
||||
}
|
||||
|
||||
fun getRewindSaveState(): RewindSaveState {
|
||||
return state
|
||||
}
|
||||
|
||||
private fun getDurationString(context: Context, duration: Duration): String {
|
||||
val minutes = duration.toMinutes()
|
||||
return if (minutes >= 1) {
|
||||
val seconds = duration.minusMinutes(minutes).toMillis() / 1000f
|
||||
context.getString(R.string.rewind_time_minutes_seconds, minutes, SECONDS_FORMATTER.format(seconds))
|
||||
} else {
|
||||
val seconds = duration.toMillis() / 1000f
|
||||
context.getString(R.string.rewind_time_seconds, SECONDS_FORMATTER.format(seconds))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var currentRewindWindow: RewindWindow? = null
|
||||
private var recyclerView: RecyclerView? = null
|
||||
|
||||
fun setRewindWindow(rewindWindow: RewindWindow) {
|
||||
currentRewindWindow = rewindWindow
|
||||
notifyDataSetChanged()
|
||||
// Reset scroll
|
||||
recyclerView?.scrollToPosition(0)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RewindSaveStateViewHolder {
|
||||
val binding = ItemRewindSaveStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
val holder = RewindSaveStateViewHolder(parent.context, binding)
|
||||
binding.root.setOnClickListener {
|
||||
onRewindSaveStateSelected(holder.getRewindSaveState())
|
||||
}
|
||||
binding.root.setOnFocusChangeListener { v, hasFocus ->
|
||||
if (hasFocus) {
|
||||
recyclerView?.let {
|
||||
// Scroll to new focused item (allows controller users to properly navigate the list)
|
||||
val linearLayoutManager = it.layoutManager as? LinearLayoutManager ?: return@let
|
||||
val position = linearLayoutManager.getPosition(v)
|
||||
val offset = (it.width - v.width - v.marginRight - v.marginLeft) / 2
|
||||
linearLayoutManager.scrollToPositionWithOffset(position, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
return holder
|
||||
}
|
||||
|
||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
this.recyclerView = recyclerView
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RewindSaveStateViewHolder, position: Int) {
|
||||
currentRewindWindow?.let {
|
||||
val state = it.rewindStates[position]
|
||||
holder.setRewindSaveState(state, it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return currentRewindWindow?.rewindStates?.size ?: 0
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
package me.magnum.melonds.ui.emulator.rewind.model
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import me.magnum.melonds.utils.DsScreenshotConverter
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
class RewindSaveState(
|
||||
val buffer: ByteBuffer,
|
||||
val screenshotBuffer: ByteBuffer,
|
||||
val frame: Int,
|
||||
) {
|
||||
val screenshot: Bitmap get() = DsScreenshotConverter.fromByteBufferToBitmap(screenshotBuffer)
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package me.magnum.melonds.ui.emulator.rewind.model
|
||||
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
|
||||
class RewindWindow(
|
||||
val currentEmulationFrame: Int,
|
||||
val rewindStates: ArrayList<RewindSaveState>
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val FRAMES_PER_SECOND = 60
|
||||
}
|
||||
|
||||
fun getDeltaFromEmulationTimeToRewindState(state: RewindSaveState): Duration {
|
||||
val elapsedFrames = currentEmulationFrame - state.frame
|
||||
val elapsedMillis = elapsedFrames.toFloat() / FRAMES_PER_SECOND * 1000
|
||||
return Duration.ofMillis(elapsedMillis.toLong())
|
||||
}
|
||||
}
|
@ -27,6 +27,7 @@ class RomEmulatorDelegate(activity: EmulatorActivity, private val picasso: Picas
|
||||
SETTINGS(R.string.settings),
|
||||
SAVE_STATE(R.string.save_state),
|
||||
LOAD_STATE(R.string.load_state),
|
||||
REWIND(R.string.rewind),
|
||||
CHEATS(R.string.cheats),
|
||||
RESET(R.string.reset),
|
||||
EXIT(R.string.exit)
|
||||
@ -142,6 +143,7 @@ class RomEmulatorDelegate(activity: EmulatorActivity, private val picasso: Picas
|
||||
loadState(it)
|
||||
activity.resumeEmulation()
|
||||
}
|
||||
RomPauseMenuOptions.REWIND -> activity.openRewindWindow()
|
||||
RomPauseMenuOptions.CHEATS -> openCheatsActivity()
|
||||
RomPauseMenuOptions.RESET -> activity.resetEmulation()
|
||||
RomPauseMenuOptions.EXIT -> activity.finish()
|
||||
|
@ -0,0 +1,24 @@
|
||||
package me.magnum.melonds.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object DsScreenshotConverter {
|
||||
private const val SCREEN_WIDTH = 256
|
||||
private const val SCREEN_HEIGHT = 384
|
||||
|
||||
fun fromByteBufferToBitmap(buffer: ByteBuffer): Bitmap {
|
||||
return Bitmap.createBitmap(SCREEN_WIDTH, SCREEN_HEIGHT, Bitmap.Config.ARGB_8888).apply {
|
||||
// Texture buffer is in BGR format. Convert to RGB
|
||||
for (x in 0 until SCREEN_WIDTH) {
|
||||
for (y in 0 until SCREEN_HEIGHT) {
|
||||
val b = buffer[(y * SCREEN_WIDTH + x) * 4 + 0].toInt() and 0xFF
|
||||
val g = buffer[(y * SCREEN_WIDTH + x) * 4 + 1].toInt() and 0xFF
|
||||
val r = buffer[(y * SCREEN_WIDTH + x) * 4 + 2].toInt() and 0xFF
|
||||
val argbPixel = 0xFF000000.toInt() or r.shl(16) or g.shl(8) or b
|
||||
setPixel(x, y, argbPixel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FFF" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
5
app/src/main/res/drawable/selector_rewind_save_state.xml
Normal file
5
app/src/main/res/drawable/selector_rewind_save_state.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true" android:drawable="@drawable/background_rewind_save_state_focused" />
|
||||
<item android:drawable="@android:color/transparent" />
|
||||
</selector>
|
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true" android:color="@android:color/black" />
|
||||
<item android:color="@android:color/white" />
|
||||
</selector>
|
@ -1,40 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/layout_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/black"
|
||||
tools:context=".ui.emulator.EmulatorActivity">
|
||||
<androidx.constraintlayout.motion.widget.MotionLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/layout_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/black"
|
||||
app:layoutDescription="@xml/scene_activity_emulator"
|
||||
tools:context=".ui.emulator.EmulatorActivity">
|
||||
|
||||
<android.opengl.GLSurfaceView
|
||||
android:id="@+id/surfaceMain"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"/>
|
||||
android:id="@+id/surfaceMain"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"/>
|
||||
|
||||
<me.magnum.melonds.ui.emulator.RuntimeLayoutView
|
||||
android:id="@+id/view_layout_controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
android:id="@+id/view_layout_controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textFps"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="12dp"
|
||||
android:textColor="@android:color/white"
|
||||
tools:layout_alignParentBottom="true"
|
||||
tools:layout_centerHorizontal="true"
|
||||
tools:text="FPS: 60"/>
|
||||
android:id="@+id/textFps"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="12dp"
|
||||
android:textColor="@android:color/white"
|
||||
tools:text="FPS: 60"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textLoading"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerInParent="true"
|
||||
android:textSize="32sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/white"
|
||||
android:text="@string/info_loading"/>
|
||||
</RelativeLayout>
|
||||
android:id="@+id/textLoading"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerInParent="true"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:textSize="32sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/white"
|
||||
android:text="@string/info_loading"/>
|
||||
|
||||
<RelativeLayout
|
||||
android:id="@+id/layout_rewind"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#A000"
|
||||
android:focusable="false">
|
||||
</RelativeLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/list_rewind"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
</androidx.constraintlayout.motion.widget.MotionLayout>
|
31
app/src/main/res/layout/item_rewind_save_state.xml
Normal file
31
app/src/main/res/layout/item_rewind_save_state.xml
Normal file
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/selector_rewind_save_state"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_screenshot"
|
||||
android:layout_width="95dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
android:adjustViewBounds="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_timestamp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@drawable/selector_rewind_save_state_time_text"
|
||||
app:layout_constraintTop_toBottomOf="@id/image_screenshot"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:duplicateParentState="true" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
@ -49,6 +49,8 @@
|
||||
<string name="cant_load_empty_slot">Can\'t load an empty save state slot</string>
|
||||
<string name="failed_reset_emulation">Failed to reset emulation</string>
|
||||
<string name="save_states_not_supported">Save states are not supported while running the firmware</string>
|
||||
<string name="rewind_time_seconds">%1$ss</string>
|
||||
<string name="rewind_time_minutes_seconds">%1$dm%2$ss</string>
|
||||
|
||||
<string name="action_sort_alphabetically">Alphabetically</string>
|
||||
<string name="action_sort_recently_played">Recently played</string>
|
||||
@ -80,6 +82,7 @@
|
||||
<string name="settings">Settings</string>
|
||||
<string name="save_state">Save state</string>
|
||||
<string name="load_state">Load state</string>
|
||||
<string name="rewind">Rewind</string>
|
||||
<string name="save_slot">Save slot</string>
|
||||
<string name="empty_slot">%1$s. <Empty></string>
|
||||
<string name="quick_slot">Quick Slot</string>
|
||||
|
63
app/src/main/res/xml/scene_activity_emulator.xml
Normal file
63
app/src/main/res/xml/scene_activity_emulator.xml
Normal file
@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<MotionScene
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<ConstraintSet android:id="@+id/rewind_hidden">
|
||||
<Constraint
|
||||
android:id="@+id/layout_rewind"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:alpha="0"
|
||||
android:visibility="invisible" />
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/list_rewind"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="parent"
|
||||
android:visibility="invisible" />
|
||||
|
||||
<Constraint android:id="@+id/textFps">
|
||||
<PropertySet
|
||||
app:applyMotionScene="false"
|
||||
app:visibilityMode="ignore" />
|
||||
</Constraint>
|
||||
|
||||
<Constraint android:id="@+id/textLoading">
|
||||
<PropertySet
|
||||
app:applyMotionScene="false"
|
||||
app:visibilityMode="ignore" />
|
||||
</Constraint>
|
||||
|
||||
<Constraint android:id="@+id/view_layout_controls">
|
||||
<PropertySet
|
||||
app:applyMotionScene="false"
|
||||
app:visibilityMode="ignore" />
|
||||
</Constraint>
|
||||
</ConstraintSet>
|
||||
|
||||
<ConstraintSet
|
||||
android:id="@+id/rewind_visible"
|
||||
app:deriveConstraintsFrom="@+id/rewind_hidden">
|
||||
|
||||
<Constraint
|
||||
android:id="@id/layout_rewind"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:alpha="1"
|
||||
android:visibility="visible" />
|
||||
|
||||
<Constraint
|
||||
android:id="@+id/list_rewind"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:visibility="visible" />
|
||||
</ConstraintSet>
|
||||
|
||||
<Transition
|
||||
app:constraintSetStart="@+id/rewind_hidden"
|
||||
app:constraintSetEnd="@id/rewind_visible"
|
||||
app:duration="250" />
|
||||
</MotionScene>
|
@ -6,6 +6,7 @@ object Dependencies {
|
||||
const val CommonsCompress = "1.21"
|
||||
const val ConstraintLayout = "2.0.4"
|
||||
const val Core = "1.6.0"
|
||||
const val Desugar = "1.1.5"
|
||||
const val DocumentFile = "1.0.1"
|
||||
const val Flexbox = "2.0.1"
|
||||
const val Fragment = "1.3.5"
|
||||
@ -38,6 +39,10 @@ object Dependencies {
|
||||
const val kotlin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.Kotlin}"
|
||||
}
|
||||
|
||||
object Tools {
|
||||
const val desugarJdkLibs = "com.android.tools:desugar_jdk_libs:${Versions.Desugar}"
|
||||
}
|
||||
|
||||
object Kotlin {
|
||||
const val kotlinStdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${Versions.Kotlin}"
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
Subproject commit bb35140d03af0034f7f759bcb497289d7e7603c5
|
||||
Subproject commit 159c113545484fc38c0d74c7f8d066a626093911
|
Loading…
x
Reference in New Issue
Block a user