mirror of
https://github.com/izzy2lost/xemu.git
synced 2026-07-06 00:20:22 -07:00
added snapshots/save states
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
#include "qemu/osdep.h"
|
||||
#include "xui/xemu-hud.h"
|
||||
|
||||
extern void xemu_android_process_snapshot_request(void);
|
||||
|
||||
void xemu_hud_init(SDL_Window *window, void *sdl_gl_context)
|
||||
{
|
||||
(void)window;
|
||||
@@ -13,6 +15,7 @@ void xemu_hud_cleanup(void)
|
||||
|
||||
void xemu_hud_render(void)
|
||||
{
|
||||
xemu_android_process_snapshot_request();
|
||||
}
|
||||
|
||||
void xemu_hud_process_sdl_events(SDL_Event *event)
|
||||
|
||||
@@ -1,13 +1,292 @@
|
||||
#include "qemu/osdep.h"
|
||||
#include "ui/xemu-snapshots.h"
|
||||
#include "qapi/error.h"
|
||||
#include "migration/snapshot.h"
|
||||
#include "migration/qemu-file.h"
|
||||
#include "system/runstate.h"
|
||||
#include "xemu-xbe.h"
|
||||
|
||||
#include <SDL.h>
|
||||
#include <SDL_system.h>
|
||||
#include <GLES3/gl3.h>
|
||||
#include <android/log.h>
|
||||
#include <glib/gstdio.h>
|
||||
#include <jni.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
const char **g_snapshot_shortcut_index_key_map[] = { NULL };
|
||||
|
||||
static bool xemu_snapshots_dirty = true;
|
||||
static GLuint g_snapshot_display_tex = 0;
|
||||
static bool g_snapshot_display_flip = false;
|
||||
|
||||
#define SNAPSHOT_PREVIEW_WIDTH 320
|
||||
#define SNAPSHOT_PREVIEW_HEIGHT 240
|
||||
#define SNAPSHOT_PREVIEW_VERSION 1
|
||||
|
||||
#define SNAP_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "xemu-android", __VA_ARGS__)
|
||||
#define SNAP_LOGW(...) __android_log_print(ANDROID_LOG_WARN, "xemu-android", __VA_ARGS__)
|
||||
|
||||
typedef struct SnapshotPreviewHeader {
|
||||
char magic[4];
|
||||
uint16_t version;
|
||||
uint16_t width;
|
||||
uint16_t height;
|
||||
uint16_t channels;
|
||||
} SnapshotPreviewHeader;
|
||||
|
||||
static void sanitize_snapshot_name(const char *in, char *out, size_t out_len)
|
||||
{
|
||||
size_t j = 0;
|
||||
|
||||
if (!out || out_len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in || !in[0]) {
|
||||
g_strlcpy(out, "snapshot", out_len);
|
||||
return;
|
||||
}
|
||||
|
||||
for (size_t i = 0; in[i] && j + 1 < out_len; ++i) {
|
||||
unsigned char c = (unsigned char)in[i];
|
||||
if (g_ascii_isalnum(c) || c == '_' || c == '-') {
|
||||
out[j++] = (char)c;
|
||||
} else {
|
||||
out[j++] = '_';
|
||||
}
|
||||
}
|
||||
|
||||
if (j == 0) {
|
||||
g_strlcpy(out, "snapshot", out_len);
|
||||
} else {
|
||||
out[j] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
static char *get_snapshot_preview_dir(void)
|
||||
{
|
||||
const char *base = SDL_AndroidGetInternalStoragePath();
|
||||
char *dir;
|
||||
|
||||
if (!base || !base[0]) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
dir = g_strdup_printf("%s/x1box/snapshots", base);
|
||||
if (g_mkdir_with_parents(dir, 0700) != 0) {
|
||||
SNAP_LOGW("failed to create snapshot preview dir: %s", dir);
|
||||
g_free(dir);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
static char *get_snapshot_title(void)
|
||||
{
|
||||
struct xbe *xbe_data = xemu_get_xbe_info();
|
||||
char *title = NULL;
|
||||
|
||||
if (xbe_data && xbe_data->cert) {
|
||||
glong items_written = 0;
|
||||
title = g_utf16_to_utf8((const gunichar2 *)xbe_data->cert->m_title_name,
|
||||
40, NULL, &items_written, NULL);
|
||||
if (title) {
|
||||
g_strstrip(title);
|
||||
if (title[0]) {
|
||||
return title;
|
||||
}
|
||||
g_free(title);
|
||||
title = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
return g_strdup("Unknown Game");
|
||||
}
|
||||
|
||||
static bool capture_snapshot_thumbnail(uint8_t **pixels_out, size_t *pixels_size_out)
|
||||
{
|
||||
GLint viewport[4] = { 0, 0, 0, 0 };
|
||||
GLint prev_pack_alignment = 4;
|
||||
uint8_t *src_pixels = NULL;
|
||||
uint8_t *dst_pixels = NULL;
|
||||
bool ok = false;
|
||||
|
||||
if (!pixels_out || !pixels_size_out) {
|
||||
return false;
|
||||
}
|
||||
|
||||
*pixels_out = NULL;
|
||||
*pixels_size_out = 0;
|
||||
|
||||
if (!SDL_GL_GetCurrentContext() || g_snapshot_display_tex == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
glGetIntegerv(GL_VIEWPORT, viewport);
|
||||
|
||||
if (viewport[2] <= 0 || viewport[3] <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
(void)g_snapshot_display_flip;
|
||||
|
||||
{
|
||||
const int src_w = viewport[2];
|
||||
const int src_h = viewport[3];
|
||||
const size_t src_bytes = (size_t)src_w * (size_t)src_h * 4;
|
||||
const size_t dst_bytes = (size_t)SNAPSHOT_PREVIEW_WIDTH *
|
||||
(size_t)SNAPSHOT_PREVIEW_HEIGHT * 4;
|
||||
|
||||
src_pixels = g_malloc(src_bytes);
|
||||
dst_pixels = g_malloc(dst_bytes);
|
||||
|
||||
glGetIntegerv(GL_PACK_ALIGNMENT, &prev_pack_alignment);
|
||||
glPixelStorei(GL_PACK_ALIGNMENT, 1);
|
||||
glReadPixels(viewport[0], viewport[1], src_w, src_h,
|
||||
GL_RGBA, GL_UNSIGNED_BYTE, src_pixels);
|
||||
glPixelStorei(GL_PACK_ALIGNMENT, prev_pack_alignment);
|
||||
if (glGetError() != GL_NO_ERROR) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
for (int y = 0; y < SNAPSHOT_PREVIEW_HEIGHT; ++y) {
|
||||
const int src_y = (int)(((int64_t)y * src_h) / SNAPSHOT_PREVIEW_HEIGHT);
|
||||
for (int x = 0; x < SNAPSHOT_PREVIEW_WIDTH; ++x) {
|
||||
const int src_x = (int)(((int64_t)x * src_w) / SNAPSHOT_PREVIEW_WIDTH);
|
||||
const size_t src_off = ((size_t)src_y * (size_t)src_w + (size_t)src_x) * 4;
|
||||
const size_t dst_off =
|
||||
((size_t)y * (size_t)SNAPSHOT_PREVIEW_WIDTH + (size_t)x) * 4;
|
||||
memcpy(dst_pixels + dst_off, src_pixels + src_off, 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*pixels_out = dst_pixels;
|
||||
*pixels_size_out = (size_t)SNAPSHOT_PREVIEW_WIDTH *
|
||||
(size_t)SNAPSHOT_PREVIEW_HEIGHT * 4;
|
||||
dst_pixels = NULL;
|
||||
ok = true;
|
||||
|
||||
cleanup:
|
||||
g_free(src_pixels);
|
||||
g_free(dst_pixels);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static void write_snapshot_preview_sidecar(const char *vm_name)
|
||||
{
|
||||
char safe_name[128];
|
||||
char *dir = NULL;
|
||||
char *thumb_path = NULL;
|
||||
char *title_path = NULL;
|
||||
char *title = NULL;
|
||||
uint8_t *pixels = NULL;
|
||||
size_t pixels_size = 0;
|
||||
|
||||
FILE *title_file = NULL;
|
||||
FILE *thumb_file = NULL;
|
||||
|
||||
if (!vm_name || !vm_name[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
sanitize_snapshot_name(vm_name, safe_name, sizeof(safe_name));
|
||||
|
||||
dir = get_snapshot_preview_dir();
|
||||
if (!dir) {
|
||||
return;
|
||||
}
|
||||
|
||||
thumb_path = g_strdup_printf("%s/%s.thm", dir, safe_name);
|
||||
title_path = g_strdup_printf("%s/%s.title", dir, safe_name);
|
||||
|
||||
title = get_snapshot_title();
|
||||
if (title) {
|
||||
title_file = fopen(title_path, "wb");
|
||||
if (title_file) {
|
||||
fwrite(title, 1, strlen(title), title_file);
|
||||
fclose(title_file);
|
||||
title_file = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
if (!capture_snapshot_thumbnail(&pixels, &pixels_size)) {
|
||||
SNAP_LOGW("snapshot preview capture failed for %s", vm_name);
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
{
|
||||
SnapshotPreviewHeader header;
|
||||
memcpy(header.magic, "X1TH", 4);
|
||||
header.version = SNAPSHOT_PREVIEW_VERSION;
|
||||
header.width = SNAPSHOT_PREVIEW_WIDTH;
|
||||
header.height = SNAPSHOT_PREVIEW_HEIGHT;
|
||||
header.channels = 4;
|
||||
|
||||
thumb_file = fopen(thumb_path, "wb");
|
||||
if (!thumb_file) {
|
||||
SNAP_LOGW("failed to open snapshot preview file: %s", thumb_path);
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (fwrite(&header, sizeof(header), 1, thumb_file) != 1 ||
|
||||
fwrite(pixels, 1, pixels_size, thumb_file) != pixels_size) {
|
||||
SNAP_LOGW("failed writing snapshot preview: %s", thumb_path);
|
||||
}
|
||||
|
||||
fclose(thumb_file);
|
||||
thumb_file = NULL;
|
||||
}
|
||||
|
||||
cleanup:
|
||||
if (thumb_file) {
|
||||
fclose(thumb_file);
|
||||
}
|
||||
if (title_file) {
|
||||
fclose(title_file);
|
||||
}
|
||||
g_free(pixels);
|
||||
g_free(title);
|
||||
g_free(title_path);
|
||||
g_free(thumb_path);
|
||||
g_free(dir);
|
||||
}
|
||||
|
||||
char *xemu_get_currently_loaded_disc_path(void)
|
||||
{
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void xemu_snapshots_save(const char *vm_name, Error **err)
|
||||
{
|
||||
save_snapshot(vm_name, true, NULL, false, NULL, err);
|
||||
xemu_snapshots_dirty = true;
|
||||
}
|
||||
|
||||
void xemu_snapshots_load(const char *vm_name, Error **err)
|
||||
{
|
||||
bool was_running = runstate_is_running();
|
||||
vm_stop(RUN_STATE_RESTORE_VM);
|
||||
if (load_snapshot(vm_name, NULL, false, NULL, err) && was_running) {
|
||||
vm_start();
|
||||
}
|
||||
}
|
||||
|
||||
void xemu_snapshots_delete(const char *vm_name, Error **err)
|
||||
{
|
||||
delete_snapshot(vm_name, false, NULL, err);
|
||||
xemu_snapshots_dirty = true;
|
||||
}
|
||||
|
||||
void xemu_snapshots_mark_dirty(void)
|
||||
{
|
||||
xemu_snapshots_dirty = true;
|
||||
}
|
||||
|
||||
int xemu_snapshots_list(QEMUSnapshotInfo **info, XemuSnapshotData **extra_data,
|
||||
Error **err)
|
||||
{
|
||||
@@ -21,49 +300,56 @@ int xemu_snapshots_list(QEMUSnapshotInfo **info, XemuSnapshotData **extra_data,
|
||||
return 0;
|
||||
}
|
||||
|
||||
void xemu_snapshots_load(const char *vm_name, Error **err)
|
||||
{
|
||||
(void)vm_name;
|
||||
if (err) {
|
||||
*err = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
void xemu_snapshots_save(const char *vm_name, Error **err)
|
||||
{
|
||||
(void)vm_name;
|
||||
if (err) {
|
||||
*err = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
void xemu_snapshots_delete(const char *vm_name, Error **err)
|
||||
{
|
||||
(void)vm_name;
|
||||
if (err) {
|
||||
*err = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
void xemu_snapshots_save_extra_data(QEMUFile *f)
|
||||
{
|
||||
(void)f;
|
||||
char *title = get_snapshot_title();
|
||||
size_t title_size = title ? strlen(title) : 0;
|
||||
|
||||
if (title_size > 255) {
|
||||
title_size = 255;
|
||||
}
|
||||
|
||||
qemu_put_be32(f, XEMU_SNAPSHOT_DATA_MAGIC);
|
||||
qemu_put_be32(f, XEMU_SNAPSHOT_DATA_VERSION);
|
||||
qemu_put_be32(f, 4 + 1 + title_size + 4);
|
||||
qemu_put_be32(f, 0);
|
||||
qemu_put_byte(f, (uint8_t)title_size);
|
||||
if (title_size) {
|
||||
qemu_put_buffer(f, (const uint8_t *)title, title_size);
|
||||
}
|
||||
qemu_put_be32(f, 0);
|
||||
|
||||
g_free(title);
|
||||
xemu_snapshots_dirty = true;
|
||||
}
|
||||
|
||||
bool xemu_snapshots_offset_extra_data(QEMUFile *f)
|
||||
{
|
||||
(void)f;
|
||||
return false;
|
||||
}
|
||||
unsigned int v;
|
||||
uint32_t size;
|
||||
|
||||
void xemu_snapshots_mark_dirty(void)
|
||||
{
|
||||
v = qemu_get_be32(f);
|
||||
if (v != XEMU_SNAPSHOT_DATA_MAGIC) {
|
||||
qemu_file_skip(f, -4);
|
||||
return true;
|
||||
}
|
||||
|
||||
qemu_get_be32(f);
|
||||
size = qemu_get_be32(f);
|
||||
|
||||
{
|
||||
void *buf = g_malloc(size);
|
||||
qemu_get_buffer(f, buf, size);
|
||||
g_free(buf);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void xemu_snapshots_set_framebuffer_texture(GLuint tex, bool flip)
|
||||
{
|
||||
(void)tex;
|
||||
(void)flip;
|
||||
g_snapshot_display_tex = tex;
|
||||
g_snapshot_display_flip = flip;
|
||||
}
|
||||
|
||||
bool xemu_snapshots_load_png_to_texture(GLuint tex, void *buf, size_t size)
|
||||
@@ -81,3 +367,96 @@ void *xemu_snapshots_create_framebuffer_thumbnail_png(size_t *size)
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
typedef enum SnapOpType {
|
||||
SNAP_NONE,
|
||||
SNAP_SAVE,
|
||||
SNAP_LOAD,
|
||||
} SnapOpType;
|
||||
|
||||
static struct {
|
||||
pthread_mutex_t lock;
|
||||
pthread_cond_t cond;
|
||||
SnapOpType type;
|
||||
char name[128];
|
||||
bool pending;
|
||||
bool done;
|
||||
bool success;
|
||||
} g_snap_req = {
|
||||
.lock = PTHREAD_MUTEX_INITIALIZER,
|
||||
.cond = PTHREAD_COND_INITIALIZER,
|
||||
};
|
||||
|
||||
void xemu_android_process_snapshot_request(void)
|
||||
{
|
||||
if (pthread_mutex_trylock(&g_snap_req.lock) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!g_snap_req.pending) {
|
||||
pthread_mutex_unlock(&g_snap_req.lock);
|
||||
return;
|
||||
}
|
||||
|
||||
Error *err = NULL;
|
||||
|
||||
if (g_snap_req.type == SNAP_SAVE) {
|
||||
xemu_snapshots_save(g_snap_req.name, &err);
|
||||
if (!err) {
|
||||
write_snapshot_preview_sidecar(g_snap_req.name);
|
||||
}
|
||||
} else if (g_snap_req.type == SNAP_LOAD) {
|
||||
xemu_snapshots_load(g_snap_req.name, &err);
|
||||
}
|
||||
|
||||
if (err) {
|
||||
SNAP_LOGW("snapshot op failed: %s", error_get_pretty(err));
|
||||
error_free(err);
|
||||
g_snap_req.success = false;
|
||||
} else {
|
||||
g_snap_req.success = true;
|
||||
}
|
||||
|
||||
g_snap_req.pending = false;
|
||||
g_snap_req.done = true;
|
||||
pthread_cond_signal(&g_snap_req.cond);
|
||||
pthread_mutex_unlock(&g_snap_req.lock);
|
||||
}
|
||||
|
||||
static jboolean dispatch_snapshot(JNIEnv *env, jstring jname, SnapOpType type)
|
||||
{
|
||||
const char *name = (*env)->GetStringUTFChars(env, jname, NULL);
|
||||
|
||||
pthread_mutex_lock(&g_snap_req.lock);
|
||||
g_snap_req.type = type;
|
||||
g_snap_req.pending = true;
|
||||
g_snap_req.done = false;
|
||||
strncpy(g_snap_req.name, name, sizeof(g_snap_req.name) - 1);
|
||||
g_snap_req.name[sizeof(g_snap_req.name) - 1] = '\0';
|
||||
(*env)->ReleaseStringUTFChars(env, jname, name);
|
||||
|
||||
while (!g_snap_req.done) {
|
||||
pthread_cond_wait(&g_snap_req.cond, &g_snap_req.lock);
|
||||
}
|
||||
|
||||
jboolean ok = (jboolean)g_snap_req.success;
|
||||
g_snap_req.type = SNAP_NONE;
|
||||
pthread_mutex_unlock(&g_snap_req.lock);
|
||||
return ok;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_izzy2lost_x1box_MainActivity_nativeSaveSnapshot(
|
||||
JNIEnv *env, jobject obj, jstring name)
|
||||
{
|
||||
(void)obj;
|
||||
return dispatch_snapshot(env, name, SNAP_SAVE);
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_izzy2lost_x1box_MainActivity_nativeLoadSnapshot(
|
||||
JNIEnv *env, jobject obj, jstring name)
|
||||
{
|
||||
(void)obj;
|
||||
return dispatch_snapshot(env, name, SNAP_LOAD);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.izzy2lost.x1box
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableStringBuilder
|
||||
@@ -10,9 +11,12 @@ import android.text.method.LinkMovementMethod
|
||||
import android.text.style.ClickableSpan
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ListView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Space
|
||||
import android.widget.TextView
|
||||
@@ -30,6 +34,8 @@ import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.ArrayDeque
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -37,6 +43,8 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
class GameLibraryActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
const val EXTRA_RESTART_LAST_GAME = "com.izzy2lost.x1box.extra.RESTART_LAST_GAME"
|
||||
private const val SNAPSHOT_PREVIEW_HEADER_SIZE = 12
|
||||
private const val TOTAL_SNAPSHOT_SLOTS = 10
|
||||
}
|
||||
|
||||
private data class GameEntry(
|
||||
@@ -53,6 +61,13 @@ class GameLibraryActivity : AppCompatActivity() {
|
||||
val url: String
|
||||
)
|
||||
|
||||
private data class SnapshotSlotPreview(
|
||||
val slot: Int,
|
||||
val slotLabel: String,
|
||||
val gameTitle: String,
|
||||
val thumbnail: Bitmap?,
|
||||
)
|
||||
|
||||
private val prefs by lazy { getSharedPreferences("x1box_prefs", MODE_PRIVATE) }
|
||||
private val gameExts = setOf("iso", "xiso", "cso", "cci")
|
||||
private val titleStopWords = setOf("the", "a", "an", "and", "of", "for", "in", "on", "to")
|
||||
@@ -72,6 +87,7 @@ class GameLibraryActivity : AppCompatActivity() {
|
||||
private lateinit var gamesGridContainer: LinearLayout
|
||||
private lateinit var btnChangeFolder: MaterialButton
|
||||
private lateinit var btnSettings: MaterialButton
|
||||
private lateinit var btnSnapshots: MaterialButton
|
||||
private lateinit var btnConvertIso: MaterialButton
|
||||
private lateinit var btnAbout: ImageButton
|
||||
private lateinit var viewModeToggle: MaterialButtonToggleGroup
|
||||
@@ -112,6 +128,7 @@ class GameLibraryActivity : AppCompatActivity() {
|
||||
gamesGridContainer = findViewById(R.id.library_games_grid_container)
|
||||
btnChangeFolder = findViewById(R.id.btn_change_games_folder)
|
||||
btnSettings = findViewById(R.id.btn_settings)
|
||||
btnSnapshots = findViewById(R.id.btn_snapshots)
|
||||
btnConvertIso = findViewById(R.id.btn_convert_iso)
|
||||
btnAbout = findViewById(R.id.btn_library_about)
|
||||
viewModeToggle = findViewById(R.id.library_view_mode_toggle)
|
||||
@@ -135,6 +152,9 @@ class GameLibraryActivity : AppCompatActivity() {
|
||||
btnSettings.setOnClickListener {
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
}
|
||||
btnSnapshots.setOnClickListener {
|
||||
showSnapshotStartupPicker()
|
||||
}
|
||||
btnConvertIso.setOnClickListener {
|
||||
showIsoConversionPicker()
|
||||
}
|
||||
@@ -216,6 +236,226 @@ class GameLibraryActivity : AppCompatActivity() {
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun slotName(slot: Int) = "android_slot_$slot"
|
||||
|
||||
private fun snapshotPreviewDir(): File = File(filesDir, "x1box/snapshots")
|
||||
|
||||
private fun snapshotPreviewDirs(): List<File> {
|
||||
val dirs = ArrayList<File>(2)
|
||||
dirs.add(snapshotPreviewDir())
|
||||
getExternalFilesDir(null)?.let { dirs.add(File(it, "x1box/snapshots")) }
|
||||
return dirs.distinctBy { it.absolutePath }
|
||||
}
|
||||
|
||||
private fun slotNameAliases(slot: Int): List<String> {
|
||||
val aliases = linkedSetOf(
|
||||
slotName(slot),
|
||||
"slot_$slot",
|
||||
"slot$slot",
|
||||
"snapshot_$slot",
|
||||
)
|
||||
return aliases.toList()
|
||||
}
|
||||
|
||||
private fun resolveSnapshotPreviewFile(slot: Int, extension: String): File? {
|
||||
for (dir in snapshotPreviewDirs()) {
|
||||
for (name in slotNameAliases(slot)) {
|
||||
val file = File(dir, "$name.$extension")
|
||||
if (file.isFile) {
|
||||
return file
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun extractDisplayName(rawName: String?): String? {
|
||||
if (rawName.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
val decoded = Uri.decode(rawName)
|
||||
val leaf = decoded.substringAfterLast('/').substringAfterLast(':')
|
||||
if (leaf.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val stem = leaf.substringBeforeLast('.', leaf).trim()
|
||||
return stem.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun fallbackCurrentGameName(): String {
|
||||
val pathName = extractDisplayName(prefs.getString("dvdPath", null)?.let { File(it).name })
|
||||
if (!pathName.isNullOrEmpty()) {
|
||||
return pathName
|
||||
}
|
||||
val uriName = extractDisplayName(prefs.getString("dvdUri", null))
|
||||
if (!uriName.isNullOrEmpty()) {
|
||||
return uriName
|
||||
}
|
||||
return getString(R.string.snapshot_unknown_game)
|
||||
}
|
||||
|
||||
private fun readSnapshotGameTitle(slot: Int): String {
|
||||
val title = runCatching {
|
||||
val file = resolveSnapshotPreviewFile(slot, "title")
|
||||
if (file != null && file.exists()) {
|
||||
file.readText(Charsets.UTF_8).trim()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}.getOrDefault("")
|
||||
|
||||
if (title.isNotEmpty()) {
|
||||
return title
|
||||
}
|
||||
|
||||
return if (resolveSnapshotPreviewFile(slot, "thm") != null) {
|
||||
fallbackCurrentGameName()
|
||||
} else {
|
||||
getString(R.string.snapshot_empty_slot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeSnapshotThumbnail(slot: Int): Bitmap? {
|
||||
val sourceFile = resolveSnapshotPreviewFile(slot, "thm") ?: return null
|
||||
val bytes = runCatching { sourceFile.readBytes() }.getOrNull() ?: return null
|
||||
if (bytes.size < SNAPSHOT_PREVIEW_HEADER_SIZE) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (bytes[0] != 'X'.code.toByte() ||
|
||||
bytes[1] != '1'.code.toByte() ||
|
||||
bytes[2] != 'T'.code.toByte() ||
|
||||
bytes[3] != 'H'.code.toByte()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val header = ByteBuffer.wrap(bytes, 4, 8).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val version = header.short.toInt() and 0xFFFF
|
||||
val width = header.short.toInt() and 0xFFFF
|
||||
val height = header.short.toInt() and 0xFFFF
|
||||
val channels = header.short.toInt() and 0xFFFF
|
||||
|
||||
if (version != 1 || channels != 4 || width <= 0 || height <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
val pixelBytesLong = width.toLong() * height.toLong() * channels.toLong()
|
||||
if (pixelBytesLong <= 0 || pixelBytesLong > Int.MAX_VALUE) {
|
||||
return null
|
||||
}
|
||||
|
||||
val pixelBytes = pixelBytesLong.toInt()
|
||||
if (bytes.size < SNAPSHOT_PREVIEW_HEADER_SIZE + pixelBytes) {
|
||||
return null
|
||||
}
|
||||
|
||||
val pixels = IntArray(width * height)
|
||||
var src = SNAPSHOT_PREVIEW_HEADER_SIZE
|
||||
for (y in 0 until height) {
|
||||
val dstRow = (height - 1 - y) * width
|
||||
for (x in 0 until width) {
|
||||
val r = bytes[src].toInt() and 0xFF
|
||||
val g = bytes[src + 1].toInt() and 0xFF
|
||||
val b = bytes[src + 2].toInt() and 0xFF
|
||||
pixels[dstRow + x] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||
src += 4
|
||||
}
|
||||
}
|
||||
|
||||
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
private fun loadSnapshotSlotPreviews(): List<SnapshotSlotPreview> {
|
||||
return (1..TOTAL_SNAPSHOT_SLOTS).map { slot ->
|
||||
SnapshotSlotPreview(
|
||||
slot = slot,
|
||||
slotLabel = getString(R.string.snapshot_slot_label, slot),
|
||||
gameTitle = readSnapshotGameTitle(slot),
|
||||
thumbnail = decodeSnapshotThumbnail(slot),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSnapshotPreviewDialog(preview: SnapshotSlotPreview) {
|
||||
val bitmap = preview.thumbnail ?: return
|
||||
val image = ImageView(this).apply {
|
||||
setImageBitmap(bitmap)
|
||||
adjustViewBounds = true
|
||||
scaleType = ImageView.ScaleType.FIT_CENTER
|
||||
setPadding(16, 16, 16, 16)
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog)
|
||||
.setTitle(getString(R.string.snapshot_preview_title, preview.slot, preview.gameTitle))
|
||||
.setView(image)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun launchMainActivityWithSnapshot(slot: Int) {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
putExtra(MainActivity.EXTRA_AUTO_LOAD_SNAPSHOT_SLOT, slot)
|
||||
}
|
||||
startActivity(intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun showSnapshotStartupPicker() {
|
||||
val previews = loadSnapshotSlotPreviews()
|
||||
val listView = ListView(this)
|
||||
lateinit var dialog: androidx.appcompat.app.AlertDialog
|
||||
|
||||
val adapter = object : BaseAdapter() {
|
||||
override fun getCount(): Int = previews.size
|
||||
override fun getItem(position: Int): SnapshotSlotPreview = previews[position]
|
||||
override fun getItemId(position: Int): Long = previews[position].slot.toLong()
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: layoutInflater.inflate(R.layout.item_snapshot_slot, parent, false)
|
||||
val preview = getItem(position)
|
||||
|
||||
val slotLabel = view.findViewById<TextView>(R.id.snapshot_slot_label)
|
||||
val gameTitle = view.findViewById<TextView>(R.id.snapshot_game_title)
|
||||
val previewHint = view.findViewById<TextView>(R.id.snapshot_preview_hint)
|
||||
val thumbnail = view.findViewById<ImageView>(R.id.snapshot_thumbnail)
|
||||
|
||||
slotLabel.text = preview.slotLabel
|
||||
gameTitle.text = preview.gameTitle
|
||||
|
||||
if (preview.thumbnail != null) {
|
||||
thumbnail.setImageBitmap(preview.thumbnail)
|
||||
previewHint.text = getString(R.string.snapshot_preview_tap_hint)
|
||||
previewHint.visibility = View.VISIBLE
|
||||
thumbnail.setOnClickListener {
|
||||
showSnapshotPreviewDialog(preview)
|
||||
}
|
||||
} else {
|
||||
thumbnail.setImageResource(android.R.drawable.ic_menu_report_image)
|
||||
previewHint.text = getString(R.string.snapshot_preview_unavailable)
|
||||
previewHint.visibility = View.VISIBLE
|
||||
thumbnail.setOnClickListener(null)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
listView.adapter = adapter
|
||||
listView.setOnItemClickListener { _, _, position, _ ->
|
||||
val slot = previews[position].slot
|
||||
dialog.dismiss()
|
||||
launchMainActivityWithSnapshot(slot)
|
||||
}
|
||||
|
||||
dialog = MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog)
|
||||
.setTitle(R.string.snapshot_select_load_slot)
|
||||
.setView(listView)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun loadGames() {
|
||||
val folderUri = gamesFolderUri
|
||||
if (!isFolderReady(folderUri)) {
|
||||
|
||||
@@ -2,20 +2,44 @@ package com.izzy2lost.x1box
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.hardware.input.InputManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowInsetsController
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.ListView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.libsdl.app.SDLActivity
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
class MainActivity : SDLActivity(), InputManager.InputDeviceListener {
|
||||
companion object {
|
||||
const val EXTRA_AUTO_LOAD_SNAPSHOT_SLOT = "com.izzy2lost.x1box.extra.AUTO_LOAD_SNAPSHOT_SLOT"
|
||||
private const val SNAPSHOT_PREVIEW_HEADER_SIZE = 12
|
||||
private const val TOTAL_SNAPSHOT_SLOTS = 10
|
||||
}
|
||||
|
||||
private data class SnapshotSlotPreview(
|
||||
val slot: Int,
|
||||
val slotLabel: String,
|
||||
val gameTitle: String,
|
||||
val thumbnail: Bitmap?,
|
||||
)
|
||||
|
||||
private var onScreenController: OnScreenController? = null
|
||||
private var controllerBridge: ControllerInputBridge? = null
|
||||
private var isControllerVisible = false
|
||||
@@ -25,9 +49,15 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener {
|
||||
private var startButtonDown = false
|
||||
private var selectButtonDown = false
|
||||
private var comboTriggered = false
|
||||
private var startupSnapshotSlot: Int? = null
|
||||
private var startupSnapshotLoadScheduled = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val requestedSlot = intent?.getIntExtra(EXTRA_AUTO_LOAD_SNAPSHOT_SLOT, 0) ?: 0
|
||||
if (requestedSlot in 1..TOTAL_SNAPSHOT_SLOTS) {
|
||||
startupSnapshotSlot = requestedSlot
|
||||
}
|
||||
setupOnScreenController()
|
||||
setupControllerDetection()
|
||||
hideSystemUI()
|
||||
@@ -119,6 +149,35 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener {
|
||||
mLayout?.postDelayed({
|
||||
registerVirtualController()
|
||||
}, 1000)
|
||||
|
||||
scheduleStartupSnapshotLoadIfRequested()
|
||||
}
|
||||
|
||||
private fun scheduleStartupSnapshotLoadIfRequested() {
|
||||
val slot = startupSnapshotSlot ?: return
|
||||
if (startupSnapshotLoadScheduled) {
|
||||
return
|
||||
}
|
||||
startupSnapshotLoadScheduled = true
|
||||
|
||||
val hostView = mLayout ?: window.decorView
|
||||
hostView.postDelayed({
|
||||
Thread {
|
||||
val ok = nativeLoadSnapshot(slotName(slot))
|
||||
runOnUiThread {
|
||||
if (ok) {
|
||||
writeSnapshotTitleFallback(slot)
|
||||
}
|
||||
val msg = if (ok) {
|
||||
getString(R.string.snapshot_loaded, slot)
|
||||
} else {
|
||||
getString(R.string.snapshot_load_failed, slot)
|
||||
}
|
||||
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}.start()
|
||||
startupSnapshotSlot = null
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
private fun registerVirtualController() {
|
||||
@@ -285,6 +344,273 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener {
|
||||
((source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK)
|
||||
}
|
||||
|
||||
private external fun nativeSaveSnapshot(name: String): Boolean
|
||||
private external fun nativeLoadSnapshot(name: String): Boolean
|
||||
|
||||
private fun slotName(slot: Int) = "android_slot_$slot"
|
||||
|
||||
private fun snapshotPreviewDir(): File = File(filesDir, "x1box/snapshots")
|
||||
|
||||
private fun snapshotPreviewDirs(): List<File> {
|
||||
val dirs = ArrayList<File>(2)
|
||||
dirs.add(snapshotPreviewDir())
|
||||
getExternalFilesDir(null)?.let { dirs.add(File(it, "x1box/snapshots")) }
|
||||
return dirs.distinctBy { it.absolutePath }
|
||||
}
|
||||
|
||||
private fun slotNameAliases(slot: Int): List<String> {
|
||||
val aliases = linkedSetOf(
|
||||
slotName(slot),
|
||||
"slot_$slot",
|
||||
"slot$slot",
|
||||
"snapshot_$slot",
|
||||
)
|
||||
return aliases.toList()
|
||||
}
|
||||
|
||||
private fun resolveSnapshotPreviewFile(slot: Int, extension: String): File? {
|
||||
for (dir in snapshotPreviewDirs()) {
|
||||
for (name in slotNameAliases(slot)) {
|
||||
val file = File(dir, "$name.$extension")
|
||||
if (file.isFile) {
|
||||
return file
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun snapshotPreviewTitleFile(slot: Int): File =
|
||||
File(snapshotPreviewDir(), "${slotName(slot)}.title")
|
||||
|
||||
private fun extractDisplayName(rawName: String?): String? {
|
||||
if (rawName.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
val decoded = Uri.decode(rawName)
|
||||
val leaf = decoded.substringAfterLast('/').substringAfterLast(':')
|
||||
if (leaf.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val stem = leaf.substringBeforeLast('.', leaf).trim()
|
||||
return stem.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun fallbackCurrentGameName(): String {
|
||||
val prefs = getSharedPreferences("x1box_prefs", MODE_PRIVATE)
|
||||
val pathName = extractDisplayName(prefs.getString("dvdPath", null)?.let { File(it).name })
|
||||
if (!pathName.isNullOrEmpty()) {
|
||||
return pathName
|
||||
}
|
||||
val uriName = extractDisplayName(prefs.getString("dvdUri", null))
|
||||
if (!uriName.isNullOrEmpty()) {
|
||||
return uriName
|
||||
}
|
||||
return getString(R.string.snapshot_unknown_game)
|
||||
}
|
||||
|
||||
private fun writeSnapshotTitleFallback(slot: Int) {
|
||||
val file = snapshotPreviewTitleFile(slot)
|
||||
if (runCatching { file.exists() && file.readText(Charsets.UTF_8).trim().isNotEmpty() }.getOrDefault(false)) {
|
||||
return
|
||||
}
|
||||
|
||||
val title = fallbackCurrentGameName().trim()
|
||||
if (title.isEmpty() || title == getString(R.string.snapshot_unknown_game)) {
|
||||
return
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val dir = snapshotPreviewDir()
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
file.writeText(title, Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readSnapshotGameTitle(slot: Int): String {
|
||||
val title = runCatching {
|
||||
val file = resolveSnapshotPreviewFile(slot, "title")
|
||||
if (file != null && file.exists()) {
|
||||
file.readText(Charsets.UTF_8).trim()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}.getOrDefault("")
|
||||
|
||||
if (title.isNotEmpty()) {
|
||||
return title
|
||||
}
|
||||
|
||||
return if (resolveSnapshotPreviewFile(slot, "thm") != null) {
|
||||
fallbackCurrentGameName()
|
||||
} else {
|
||||
getString(R.string.snapshot_empty_slot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeSnapshotThumbnail(slot: Int): Bitmap? {
|
||||
val sourceFile = resolveSnapshotPreviewFile(slot, "thm") ?: return null
|
||||
val bytes = runCatching { sourceFile.readBytes() }.getOrNull() ?: return null
|
||||
if (bytes.size < SNAPSHOT_PREVIEW_HEADER_SIZE) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (bytes[0] != 'X'.code.toByte() ||
|
||||
bytes[1] != '1'.code.toByte() ||
|
||||
bytes[2] != 'T'.code.toByte() ||
|
||||
bytes[3] != 'H'.code.toByte()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val header = ByteBuffer.wrap(bytes, 4, 8).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val version = header.short.toInt() and 0xFFFF
|
||||
val width = header.short.toInt() and 0xFFFF
|
||||
val height = header.short.toInt() and 0xFFFF
|
||||
val channels = header.short.toInt() and 0xFFFF
|
||||
|
||||
if (version != 1 || channels != 4 || width <= 0 || height <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
val pixelBytesLong = width.toLong() * height.toLong() * channels.toLong()
|
||||
if (pixelBytesLong <= 0 || pixelBytesLong > Int.MAX_VALUE) {
|
||||
return null
|
||||
}
|
||||
|
||||
val pixelBytes = pixelBytesLong.toInt()
|
||||
if (bytes.size < SNAPSHOT_PREVIEW_HEADER_SIZE + pixelBytes) {
|
||||
return null
|
||||
}
|
||||
|
||||
val pixels = IntArray(width * height)
|
||||
var src = SNAPSHOT_PREVIEW_HEADER_SIZE
|
||||
for (y in 0 until height) {
|
||||
val dstRow = (height - 1 - y) * width
|
||||
for (x in 0 until width) {
|
||||
val r = bytes[src].toInt() and 0xFF
|
||||
val g = bytes[src + 1].toInt() and 0xFF
|
||||
val b = bytes[src + 2].toInt() and 0xFF
|
||||
pixels[dstRow + x] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||
src += 4
|
||||
}
|
||||
}
|
||||
|
||||
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
private fun loadSnapshotSlotPreviews(): List<SnapshotSlotPreview> {
|
||||
return (1..TOTAL_SNAPSHOT_SLOTS).map { slot ->
|
||||
SnapshotSlotPreview(
|
||||
slot = slot,
|
||||
slotLabel = getString(R.string.snapshot_slot_label, slot),
|
||||
gameTitle = readSnapshotGameTitle(slot),
|
||||
thumbnail = decodeSnapshotThumbnail(slot),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSnapshotPreviewDialog(preview: SnapshotSlotPreview) {
|
||||
val bitmap = preview.thumbnail ?: return
|
||||
val image = ImageView(this).apply {
|
||||
setImageBitmap(bitmap)
|
||||
adjustViewBounds = true
|
||||
scaleType = ImageView.ScaleType.FIT_CENTER
|
||||
setPadding(16, 16, 16, 16)
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog)
|
||||
.setTitle(getString(R.string.snapshot_preview_title, preview.slot, preview.gameTitle))
|
||||
.setView(image)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun runSnapshotOperation(slot: Int, save: Boolean) {
|
||||
Thread {
|
||||
val ok = if (save) nativeSaveSnapshot(slotName(slot)) else nativeLoadSnapshot(slotName(slot))
|
||||
runOnUiThread {
|
||||
if (ok) {
|
||||
writeSnapshotTitleFallback(slot)
|
||||
}
|
||||
val msg = if (save) {
|
||||
if (ok) getString(R.string.snapshot_saved, slot) else getString(R.string.snapshot_save_failed)
|
||||
} else {
|
||||
if (ok) getString(R.string.snapshot_loaded, slot) else getString(R.string.snapshot_load_failed, slot)
|
||||
}
|
||||
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun showSnapshotSlotDialog(save: Boolean) {
|
||||
val previews = loadSnapshotSlotPreviews()
|
||||
val listView = ListView(this)
|
||||
lateinit var dialog: AlertDialog
|
||||
|
||||
val adapter = object : BaseAdapter() {
|
||||
override fun getCount(): Int = previews.size
|
||||
override fun getItem(position: Int): SnapshotSlotPreview = previews[position]
|
||||
override fun getItemId(position: Int): Long = previews[position].slot.toLong()
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: layoutInflater.inflate(R.layout.item_snapshot_slot, parent, false)
|
||||
val preview = getItem(position)
|
||||
|
||||
val slotLabel = view.findViewById<TextView>(R.id.snapshot_slot_label)
|
||||
val gameTitle = view.findViewById<TextView>(R.id.snapshot_game_title)
|
||||
val previewHint = view.findViewById<TextView>(R.id.snapshot_preview_hint)
|
||||
val thumbnail = view.findViewById<ImageView>(R.id.snapshot_thumbnail)
|
||||
|
||||
slotLabel.text = preview.slotLabel
|
||||
gameTitle.text = preview.gameTitle
|
||||
|
||||
if (preview.thumbnail != null) {
|
||||
thumbnail.setImageBitmap(preview.thumbnail)
|
||||
previewHint.text = getString(R.string.snapshot_preview_tap_hint)
|
||||
previewHint.visibility = View.VISIBLE
|
||||
thumbnail.setOnClickListener {
|
||||
showSnapshotPreviewDialog(preview)
|
||||
}
|
||||
} else {
|
||||
thumbnail.setImageResource(android.R.drawable.ic_menu_report_image)
|
||||
previewHint.text = getString(R.string.snapshot_preview_unavailable)
|
||||
previewHint.visibility = View.VISIBLE
|
||||
thumbnail.setOnClickListener(null)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
listView.adapter = adapter
|
||||
listView.setOnItemClickListener { _, _, position, _ ->
|
||||
val slot = previews[position].slot
|
||||
dialog.dismiss()
|
||||
runSnapshotOperation(slot, save)
|
||||
}
|
||||
|
||||
dialog = MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_Xemu_RoundedDialog)
|
||||
.setTitle(
|
||||
if (save) getString(R.string.snapshot_select_save_slot)
|
||||
else getString(R.string.snapshot_select_load_slot)
|
||||
)
|
||||
.setView(listView)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun showSaveStateDialog() {
|
||||
showSnapshotSlotDialog(save = true)
|
||||
}
|
||||
|
||||
private fun showLoadStateDialog() {
|
||||
showSnapshotSlotDialog(save = false)
|
||||
}
|
||||
|
||||
private fun showInGameMenu() {
|
||||
val options = arrayOf(
|
||||
getString(R.string.in_game_menu_resume),
|
||||
@@ -293,6 +619,8 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener {
|
||||
} else {
|
||||
getString(R.string.in_game_menu_show_touch_controls)
|
||||
},
|
||||
getString(R.string.in_game_menu_save_state),
|
||||
getString(R.string.in_game_menu_load_state),
|
||||
getString(R.string.in_game_menu_exit_to_library),
|
||||
getString(R.string.in_game_menu_quit_app),
|
||||
)
|
||||
@@ -301,12 +629,12 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener {
|
||||
.setTitle(getString(R.string.in_game_menu_title))
|
||||
.setItems(options) { _, which ->
|
||||
when (which) {
|
||||
0 -> {
|
||||
// Resume
|
||||
}
|
||||
0 -> { /* Resume — dismiss dialog */ }
|
||||
1 -> toggleOnScreenController()
|
||||
2 -> exitToGameLibrary()
|
||||
3 -> finishAffinity()
|
||||
2 -> showSaveStateDialog()
|
||||
3 -> showLoadStateDialog()
|
||||
4 -> exitToGameLibrary()
|
||||
5 -> finishAffinity()
|
||||
}
|
||||
}
|
||||
.setOnDismissListener {
|
||||
|
||||
@@ -97,6 +97,17 @@
|
||||
app:iconGravity="textStart"
|
||||
app:iconPadding="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_snapshots"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/library_snapshots"
|
||||
app:icon="@android:drawable/ic_menu_recent_history"
|
||||
app:iconGravity="textStart"
|
||||
app:iconPadding="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_convert_iso"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="96dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/snapshot_thumbnail"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="90dp"
|
||||
android:background="#1AFFFFFF"
|
||||
android:contentDescription="@string/snapshot_thumbnail_desc"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/snapshot_slot_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/snapshot_game_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/snapshot_preview_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
<string name="library_title">Game Library</string>
|
||||
<string name="library_change_folder">Change Folder</string>
|
||||
<string name="library_snapshots">Snapshots</string>
|
||||
<string name="library_convert_iso">Convert ISO to XISO</string>
|
||||
<string name="library_view_list">List</string>
|
||||
<string name="library_view_cover_grid">Cover Grid</string>
|
||||
@@ -108,4 +109,19 @@
|
||||
<string name="in_game_menu_hide_touch_controls">Hide Touch Controls</string>
|
||||
<string name="in_game_menu_exit_to_library">Exit to Game Library</string>
|
||||
<string name="in_game_menu_quit_app">Quit App</string>
|
||||
<string name="in_game_menu_save_state">Save State</string>
|
||||
<string name="in_game_menu_load_state">Load State</string>
|
||||
<string name="snapshot_slot_label">Slot %1$d</string>
|
||||
<string name="snapshot_saved">State saved to Slot %1$d</string>
|
||||
<string name="snapshot_save_failed">Save failed</string>
|
||||
<string name="snapshot_loaded">State loaded from Slot %1$d</string>
|
||||
<string name="snapshot_load_failed">No save found in Slot %1$d</string>
|
||||
<string name="snapshot_select_save_slot">Select slot to save</string>
|
||||
<string name="snapshot_select_load_slot">Select slot to load</string>
|
||||
<string name="snapshot_empty_slot">Empty slot</string>
|
||||
<string name="snapshot_unknown_game">Unknown game</string>
|
||||
<string name="snapshot_preview_tap_hint">Tap thumbnail to enlarge</string>
|
||||
<string name="snapshot_preview_unavailable">No screenshot yet</string>
|
||||
<string name="snapshot_preview_title">Slot %1$d - %2$s</string>
|
||||
<string name="snapshot_thumbnail_desc">Snapshot thumbnail</string>
|
||||
</resources>
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ extern "C" {
|
||||
#include "qemu/osdep.h"
|
||||
#include "block/snapshot.h"
|
||||
#if defined(ANDROID) || defined(__ANDROID__)
|
||||
typedef unsigned int GLuint;
|
||||
#include <GLES3/gl3.h>
|
||||
#else
|
||||
#include <epoxy/gl.h>
|
||||
#endif
|
||||
|
||||
@@ -1762,9 +1762,14 @@ void sdl2_gl_refresh(DisplayChangeListener *dcl)
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
#ifdef __ANDROID__
|
||||
android_blit_frame(tex, flip_required);
|
||||
#else
|
||||
#endif
|
||||
#ifdef __ANDROID__
|
||||
/*
|
||||
* Android uses a HUD stub, but snapshot JNI requests are dispatched
|
||||
* from xemu_hud_render(). Keep this hook active each frame so save/load
|
||||
* requests run on the SDL/QEMU render thread.
|
||||
*/
|
||||
xemu_snapshots_set_framebuffer_texture(tex, flip_required);
|
||||
xemu_hud_set_framebuffer_texture(tex, flip_required);
|
||||
xemu_hud_render();
|
||||
android_log_gl_error("refresh-blit");
|
||||
#else
|
||||
xemu_snapshots_set_framebuffer_texture(tex, flip_required);
|
||||
|
||||
Reference in New Issue
Block a user