Initial commit: multi-platform emulator with PS1 support

Jetpack Compose app with adaptive layouts (phone/tablet/foldable) using
Window Size Classes. Integrates a C++ libretro frontend via JNI for
actual PS1 emulation using pcsx_rearmed, with AAudio output and
ANativeWindow rendering.

- Emulation core architecture with interface for NES, SNES, N64,
  Genesis, PS1, PS2 (PS1 implemented, others placeholder)
- Touch controller overlay with D-pad (diagonal support), face buttons,
  shoulders, and analog sticks
- Bluetooth/USB controller detection and input mapping
- Customizable controller layout editor with drag-to-reposition
- Game library with auto-scan of /sdcard/Games/ subfolders
- Auto-import of libretro core .so from /sdcard/Games/cores/
- Save states and SRAM persistence
This commit is contained in:
2026-04-08 20:34:37 -04:00
commit 39e3f8b7c0
67 changed files with 4520 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Emulate"
tools:targetApi="36">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,22 @@
cmake_minimum_required(VERSION 3.22.1)
project(emulate_native)
add_library(emulate_native SHARED
jni_bridge.cpp
libretro_frontend.cpp
audio_engine.cpp
)
target_include_directories(emulate_native PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
find_library(log-lib log)
find_library(android-lib android)
target_link_libraries(emulate_native
${log-lib}
${android-lib}
aaudio
dl
)
target_compile_options(emulate_native PRIVATE -std=c++20 -Wall -O2)

View File

@@ -0,0 +1,114 @@
#include "audio_engine.h"
#include <android/log.h>
#include <algorithm>
#include <cstring>
#define LOG_TAG "AudioEngine"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
// --- RingBuffer ---
void RingBuffer::push(const int16_t* data, size_t frames) {
size_t wp = write_pos_.load(std::memory_order_relaxed);
size_t rp = read_pos_.load(std::memory_order_acquire);
for (size_t i = 0; i < frames; i++) {
size_t next = (wp + 1) % CAPACITY;
if (next == rp) break; // full, drop samples
buffer_[wp * 2] = data[i * 2];
buffer_[wp * 2 + 1] = data[i * 2 + 1];
wp = next;
}
write_pos_.store(wp, std::memory_order_release);
}
size_t RingBuffer::pop(int16_t* out, size_t frames) {
size_t rp = read_pos_.load(std::memory_order_relaxed);
size_t wp = write_pos_.load(std::memory_order_acquire);
size_t read = 0;
while (read < frames && rp != wp) {
out[read * 2] = buffer_[rp * 2];
out[read * 2 + 1] = buffer_[rp * 2 + 1];
rp = (rp + 1) % CAPACITY;
read++;
}
read_pos_.store(rp, std::memory_order_release);
return read;
}
size_t RingBuffer::available() const {
size_t wp = write_pos_.load(std::memory_order_acquire);
size_t rp = read_pos_.load(std::memory_order_acquire);
return (wp >= rp) ? (wp - rp) : (CAPACITY - rp + wp);
}
// --- AudioEngine ---
aaudio_data_callback_result_t AudioEngine::dataCallback(
AAudioStream* /*stream*/, void* userData, void* audioData, int32_t numFrames) {
auto* engine = static_cast<AudioEngine*>(userData);
auto* output = static_cast<int16_t*>(audioData);
if (!engine->isEnabled()) {
memset(output, 0, numFrames * 2 * sizeof(int16_t));
return AAUDIO_CALLBACK_RESULT_CONTINUE;
}
size_t read = engine->ringBuffer().pop(output, numFrames);
// Fill remainder with silence to prevent noise
if (read < static_cast<size_t>(numFrames)) {
memset(output + read * 2, 0, (numFrames - read) * 2 * sizeof(int16_t));
}
return AAUDIO_CALLBACK_RESULT_CONTINUE;
}
bool AudioEngine::start(int32_t sampleRate) {
AAudioStreamBuilder* builder = nullptr;
aaudio_result_t result = AAudio_createStreamBuilder(&builder);
if (result != AAUDIO_OK) {
LOGE("Failed to create stream builder: %d", result);
return false;
}
AAudioStreamBuilder_setDirection(builder, AAUDIO_DIRECTION_OUTPUT);
AAudioStreamBuilder_setSharingMode(builder, AAUDIO_SHARING_MODE_SHARED);
AAudioStreamBuilder_setSampleRate(builder, sampleRate);
AAudioStreamBuilder_setChannelCount(builder, 2);
AAudioStreamBuilder_setFormat(builder, AAUDIO_FORMAT_PCM_I16);
AAudioStreamBuilder_setPerformanceMode(builder, AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);
AAudioStreamBuilder_setDataCallback(builder, dataCallback, this);
result = AAudioStreamBuilder_openStream(builder, &stream_);
AAudioStreamBuilder_delete(builder);
if (result != AAUDIO_OK) {
LOGE("Failed to open stream: %d", result);
return false;
}
result = AAudioStream_requestStart(stream_);
if (result != AAUDIO_OK) {
LOGE("Failed to start stream: %d", result);
AAudioStream_close(stream_);
stream_ = nullptr;
return false;
}
LOGD("Audio started: %d Hz stereo", sampleRate);
return true;
}
void AudioEngine::stop() {
if (stream_) {
AAudioStream_requestStop(stream_);
AAudioStream_close(stream_);
stream_ = nullptr;
LOGD("Audio stopped");
}
}
void AudioEngine::pushSamples(const int16_t* data, size_t frames) {
ring_buffer_.push(data, frames);
}

View File

@@ -0,0 +1,39 @@
#pragma once
#include <aaudio/AAudio.h>
#include <atomic>
#include <cstdint>
// Lock-free ring buffer for audio samples (interleaved stereo int16)
class RingBuffer {
public:
static constexpr size_t CAPACITY = 8192; // frames (each frame = 2 samples L+R)
void push(const int16_t* data, size_t frames);
size_t pop(int16_t* out, size_t frames);
size_t available() const;
private:
int16_t buffer_[CAPACITY * 2]{}; // stereo
std::atomic<size_t> read_pos_{0};
std::atomic<size_t> write_pos_{0};
};
class AudioEngine {
public:
bool start(int32_t sampleRate);
void stop();
void pushSamples(const int16_t* data, size_t frames);
void setEnabled(bool enabled) { enabled_.store(enabled); }
RingBuffer& ringBuffer() { return ring_buffer_; }
bool isEnabled() const { return enabled_.load(); }
private:
AAudioStream* stream_ = nullptr;
RingBuffer ring_buffer_;
std::atomic<bool> enabled_{true};
static aaudio_data_callback_result_t dataCallback(
AAudioStream* stream, void* userData, void* audioData, int32_t numFrames);
};

View File

@@ -0,0 +1,119 @@
#include "libretro_frontend.h"
#include <jni.h>
#include <android/log.h>
#include <android/native_window_jni.h>
#define LOG_TAG "JniBridge"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
extern "C" {
JNIEXPORT jboolean JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeLoadCore(JNIEnv* env, jobject, jstring corePath) {
const char* path = env->GetStringUTFChars(corePath, nullptr);
bool result = LibretroFrontend::instance().loadCore(path);
env->ReleaseStringUTFChars(corePath, path);
return result;
}
JNIEXPORT jboolean JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeLoadGame(JNIEnv* env, jobject, jstring romPath) {
const char* path = env->GetStringUTFChars(romPath, nullptr);
bool result = LibretroFrontend::instance().loadGame(path);
env->ReleaseStringUTFChars(romPath, path);
return result;
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeStart(JNIEnv*, jobject) {
LibretroFrontend::instance().start();
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativePause(JNIEnv*, jobject) {
LibretroFrontend::instance().pause();
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeResume(JNIEnv*, jobject) {
LibretroFrontend::instance().resume();
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeStop(JNIEnv*, jobject) {
LibretroFrontend::instance().stop();
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeReset(JNIEnv*, jobject) {
LibretroFrontend::instance().reset();
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSurface(JNIEnv* env, jobject, jobject surface) {
if (surface) {
ANativeWindow* window = ANativeWindow_fromSurface(env, surface);
LibretroFrontend::instance().setWindow(window);
ANativeWindow_release(window); // setWindow does its own acquire
} else {
LibretroFrontend::instance().releaseWindow();
}
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeReleaseSurface(JNIEnv*, jobject) {
LibretroFrontend::instance().releaseWindow();
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetButtonState(JNIEnv*, jobject, jint state) {
LibretroFrontend::instance().setButtonState(static_cast<uint16_t>(state));
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetAnalogState(JNIEnv*, jobject, jint stick, jshort x, jshort y) {
LibretroFrontend::instance().setAnalogState(stick, x, y);
}
JNIEXPORT jboolean JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSaveState(JNIEnv* env, jobject, jstring path) {
const char* p = env->GetStringUTFChars(path, nullptr);
bool result = LibretroFrontend::instance().saveState(p);
env->ReleaseStringUTFChars(path, p);
return result;
}
JNIEXPORT jboolean JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeLoadState(JNIEnv* env, jobject, jstring path) {
const char* p = env->GetStringUTFChars(path, nullptr);
bool result = LibretroFrontend::instance().loadState(p);
env->ReleaseStringUTFChars(path, p);
return result;
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetAudioEnabled(JNIEnv*, jobject, jboolean enabled) {
LibretroFrontend::instance().setAudioEnabled(enabled);
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetFrameSkip(JNIEnv*, jobject, jint skip) {
LibretroFrontend::instance().setFrameSkip(skip);
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSystemDir(JNIEnv* env, jobject, jstring dir) {
const char* d = env->GetStringUTFChars(dir, nullptr);
LibretroFrontend::instance().setSystemDir(d);
env->ReleaseStringUTFChars(dir, d);
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSaveDir(JNIEnv* env, jobject, jstring dir) {
const char* d = env->GetStringUTFChars(dir, nullptr);
LibretroFrontend::instance().setSaveDir(d);
env->ReleaseStringUTFChars(dir, d);
}
} // extern "C"

245
app/src/main/cpp/libretro.h Normal file
View File

@@ -0,0 +1,245 @@
/* Minimal vendored libretro.h - stable C API for emulator cores.
* Based on the canonical libretro API specification.
* Only includes types/constants needed by our frontend. */
#ifndef LIBRETRO_H__
#define LIBRETRO_H__
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define RETRO_API_VERSION 1
/* Pixel formats */
#define RETRO_PIXEL_FORMAT_0RGB1555 0
#define RETRO_PIXEL_FORMAT_XRGB8888 1
#define RETRO_PIXEL_FORMAT_RGB565 2
/* Device types */
#define RETRO_DEVICE_NONE 0
#define RETRO_DEVICE_JOYPAD 1
#define RETRO_DEVICE_MOUSE 2
#define RETRO_DEVICE_KEYBOARD 3
#define RETRO_DEVICE_LIGHTGUN 4
#define RETRO_DEVICE_ANALOG 5
/* Joypad button IDs */
#define RETRO_DEVICE_ID_JOYPAD_B 0
#define RETRO_DEVICE_ID_JOYPAD_Y 1
#define RETRO_DEVICE_ID_JOYPAD_SELECT 2
#define RETRO_DEVICE_ID_JOYPAD_START 3
#define RETRO_DEVICE_ID_JOYPAD_UP 4
#define RETRO_DEVICE_ID_JOYPAD_DOWN 5
#define RETRO_DEVICE_ID_JOYPAD_LEFT 6
#define RETRO_DEVICE_ID_JOYPAD_RIGHT 7
#define RETRO_DEVICE_ID_JOYPAD_A 8
#define RETRO_DEVICE_ID_JOYPAD_X 9
#define RETRO_DEVICE_ID_JOYPAD_L 10
#define RETRO_DEVICE_ID_JOYPAD_R 11
#define RETRO_DEVICE_ID_JOYPAD_L2 12
#define RETRO_DEVICE_ID_JOYPAD_R2 13
#define RETRO_DEVICE_ID_JOYPAD_L3 14
#define RETRO_DEVICE_ID_JOYPAD_R3 15
/* Analog indices */
#define RETRO_DEVICE_INDEX_ANALOG_LEFT 0
#define RETRO_DEVICE_INDEX_ANALOG_RIGHT 1
#define RETRO_DEVICE_ID_ANALOG_X 0
#define RETRO_DEVICE_ID_ANALOG_Y 1
/* Memory types */
#define RETRO_MEMORY_SAVE_RAM 0
#define RETRO_MEMORY_RTC 1
#define RETRO_MEMORY_SYSTEM_RAM 2
#define RETRO_MEMORY_VIDEO_RAM 3
/* Environment callback commands */
#define RETRO_ENVIRONMENT_SET_ROTATION 1
#define RETRO_ENVIRONMENT_GET_OVERSCAN 2
#define RETRO_ENVIRONMENT_GET_CAN_DUPE 3
#define RETRO_ENVIRONMENT_SET_MESSAGE 6
#define RETRO_ENVIRONMENT_SHUTDOWN 7
#define RETRO_ENVIRONMENT_SET_PERFORMANCE_LEVEL 8
#define RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY 9
#define RETRO_ENVIRONMENT_SET_PIXEL_FORMAT 10
#define RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS 11
#define RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK 12
#define RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE 13
#define RETRO_ENVIRONMENT_SET_HW_RENDER 14
#define RETRO_ENVIRONMENT_GET_VARIABLE 15
#define RETRO_ENVIRONMENT_SET_VARIABLES 16
#define RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE 17
#define RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME 18
#define RETRO_ENVIRONMENT_GET_LIBRETRO_PATH 19
#define RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK 21
#define RETRO_ENVIRONMENT_SET_AUDIO_CALLBACK 22
#define RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE 23
#define RETRO_ENVIRONMENT_GET_INPUT_DEVICE_CAPABILITIES 24
#define RETRO_ENVIRONMENT_GET_LOG_INTERFACE 27
#define RETRO_ENVIRONMENT_GET_PERF_INTERFACE 28
#define RETRO_ENVIRONMENT_GET_CORE_ASSETS_DIRECTORY 30
#define RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY 31
#define RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO 32
#define RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO 34
#define RETRO_ENVIRONMENT_SET_CONTROLLER_INFO 35
#define RETRO_ENVIRONMENT_SET_MEMORY_MAPS 36
#define RETRO_ENVIRONMENT_SET_GEOMETRY 37
#define RETRO_ENVIRONMENT_GET_USERNAME 38
#define RETRO_ENVIRONMENT_GET_LANGUAGE 39
#define RETRO_ENVIRONMENT_SET_SUPPORT_ACHIEVEMENTS 42
#define RETRO_ENVIRONMENT_SET_SERIALIZATION_QUIRKS 44
#define RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE 47
#define RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION 52
#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS 53
#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL 54
#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY 55
#define RETRO_ENVIRONMENT_GET_PREFERRED_HW_RENDER 56
#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 67
#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL 68
#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK 69
/* Log levels */
enum retro_log_level {
RETRO_LOG_DEBUG = 0,
RETRO_LOG_INFO,
RETRO_LOG_WARN,
RETRO_LOG_ERROR
};
typedef void (*retro_log_printf_t)(enum retro_log_level level, const char *fmt, ...);
struct retro_log_callback {
retro_log_printf_t log;
};
struct retro_variable {
const char *key;
const char *value;
};
struct retro_game_info {
const char *path;
const void *data;
size_t size;
const char *meta;
};
struct retro_system_timing {
double fps;
double sample_rate;
};
struct retro_game_geometry {
unsigned base_width;
unsigned base_height;
unsigned max_width;
unsigned max_height;
float aspect_ratio;
};
struct retro_system_av_info {
struct retro_game_geometry geometry;
struct retro_system_timing timing;
};
struct retro_system_info {
const char *library_name;
const char *library_version;
const char *valid_extensions;
bool need_fullpath;
bool block_extract;
};
struct retro_message {
const char *msg;
unsigned frames;
};
struct retro_input_descriptor {
unsigned port;
unsigned device;
unsigned index;
unsigned id;
const char *description;
};
struct retro_core_option_value {
const char *value;
const char *label;
};
struct retro_core_option_definition {
const char *key;
const char *desc;
const char *info;
struct retro_core_option_value values[128];
const char *default_value;
};
struct retro_core_option_v2_category {
const char *key;
const char *desc;
const char *info;
};
struct retro_core_option_v2_definition {
const char *key;
const char *desc;
const char *desc_categorized;
const char *info;
const char *info_categorized;
const char *category_key;
struct retro_core_option_value values[128];
const char *default_value;
};
struct retro_core_options_v2 {
struct retro_core_option_v2_category *categories;
struct retro_core_option_v2_definition *definitions;
};
struct retro_core_options_v2_intl {
struct retro_core_options_v2 *us;
struct retro_core_options_v2 *local;
};
/* Callback types */
typedef bool (*retro_environment_t)(unsigned cmd, void *data);
typedef void (*retro_video_refresh_t)(const void *data, unsigned width, unsigned height, size_t pitch);
typedef void (*retro_audio_sample_t)(int16_t left, int16_t right);
typedef size_t (*retro_audio_sample_batch_t)(const int16_t *data, size_t frames);
typedef void (*retro_input_poll_t)(void);
typedef int16_t (*retro_input_state_t)(unsigned port, unsigned device, unsigned index, unsigned id);
/* Core function pointer types */
typedef void (*retro_set_environment_t)(retro_environment_t);
typedef void (*retro_set_video_refresh_t)(retro_video_refresh_t);
typedef void (*retro_set_audio_sample_t)(retro_audio_sample_t);
typedef void (*retro_set_audio_sample_batch_t)(retro_audio_sample_batch_t);
typedef void (*retro_set_input_poll_t)(retro_input_poll_t);
typedef void (*retro_set_input_state_t)(retro_input_state_t);
typedef void (*retro_init_t)(void);
typedef void (*retro_deinit_t)(void);
typedef unsigned (*retro_api_version_t)(void);
typedef void (*retro_get_system_info_t)(struct retro_system_info *info);
typedef void (*retro_get_system_av_info_t)(struct retro_system_av_info *info);
typedef void (*retro_set_controller_port_device_t)(unsigned port, unsigned device);
typedef void (*retro_reset_t)(void);
typedef void (*retro_run_t)(void);
typedef bool (*retro_load_game_t)(const struct retro_game_info *game);
typedef void (*retro_unload_game_t)(void);
typedef size_t (*retro_serialize_size_t)(void);
typedef bool (*retro_serialize_t)(void *data, size_t size);
typedef bool (*retro_unserialize_t)(const void *data, size_t size);
typedef void *(*retro_get_memory_data_t)(unsigned id);
typedef size_t (*retro_get_memory_size_t)(unsigned id);
#ifdef __cplusplus
}
#endif
#endif /* LIBRETRO_H__ */

View File

@@ -0,0 +1,556 @@
#include "libretro_frontend.h"
#include <android/log.h>
#include <android/native_window.h>
#include <dlfcn.h>
#include <chrono>
#include <cstdarg>
#include <cstring>
#include <fstream>
#include <vector>
#define LOG_TAG "LibretroFrontend"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
LibretroFrontend& LibretroFrontend::instance() {
static LibretroFrontend inst;
return inst;
}
LibretroFrontend::~LibretroFrontend() {
stop();
unloadCore();
releaseWindow();
}
// --- Core Loading ---
bool LibretroFrontend::loadCore(const char* soPath) {
unloadCore();
LOGI("Loading core: %s", soPath);
core_handle_ = dlopen(soPath, RTLD_LAZY);
if (!core_handle_) {
LOGE("dlopen failed: %s", dlerror());
return false;
}
#define LOAD_SYM(field, name) \
field = (decltype(field))dlsym(core_handle_, #name); \
if (!field) { LOGE("Missing symbol: %s", #name); unloadCore(); return false; }
LOAD_SYM(core_init_, retro_init);
LOAD_SYM(core_deinit_, retro_deinit);
LOAD_SYM(core_api_version_, retro_api_version);
LOAD_SYM(core_get_system_info_, retro_get_system_info);
LOAD_SYM(core_get_system_av_info_, retro_get_system_av_info);
LOAD_SYM(core_set_environment_, retro_set_environment);
LOAD_SYM(core_set_video_refresh_, retro_set_video_refresh);
LOAD_SYM(core_set_audio_sample_, retro_set_audio_sample);
LOAD_SYM(core_set_audio_sample_batch_, retro_set_audio_sample_batch);
LOAD_SYM(core_set_input_poll_, retro_set_input_poll);
LOAD_SYM(core_set_input_state_, retro_set_input_state);
LOAD_SYM(core_set_controller_port_device_, retro_set_controller_port_device);
LOAD_SYM(core_load_game_, retro_load_game);
LOAD_SYM(core_unload_game_, retro_unload_game);
LOAD_SYM(core_run_, retro_run);
LOAD_SYM(core_reset_, retro_reset);
LOAD_SYM(core_serialize_size_, retro_serialize_size);
LOAD_SYM(core_serialize_, retro_serialize);
LOAD_SYM(core_unserialize_, retro_unserialize);
LOAD_SYM(core_get_memory_data_, retro_get_memory_data);
LOAD_SYM(core_get_memory_size_, retro_get_memory_size);
#undef LOAD_SYM
// Set callbacks before init
core_set_environment_(environmentCallback);
core_init_();
core_set_video_refresh_(videoRefreshCallback);
core_set_audio_sample_(audioSampleCallback);
core_set_audio_sample_batch_(audioSampleBatchCallback);
core_set_input_poll_(inputPollCallback);
core_set_input_state_(inputStateCallback);
// Set controller to analog pad (DualShock) for PS1
core_set_controller_port_device_(0, RETRO_DEVICE_ANALOG);
retro_system_info sys_info{};
core_get_system_info_(&sys_info);
LOGI("Core loaded: %s %s", sys_info.library_name, sys_info.library_version);
LOGI(" need_fullpath: %d, block_extract: %d", sys_info.need_fullpath, sys_info.block_extract);
// Set default core options for pcsx_rearmed
core_options_["pcsx_rearmed_drc"] = "enabled";
core_options_["pcsx_rearmed_pad1type"] = "analog";
core_options_["pcsx_rearmed_frameskip_type"] = "disabled";
core_options_["pcsx_rearmed_region"] = "auto";
core_options_["pcsx_rearmed_memcard2"] = "disabled";
return true;
}
bool LibretroFrontend::loadGame(const char* romPath) {
if (!core_handle_) {
LOGE("No core loaded");
return false;
}
LOGI("Loading game: %s", romPath);
// pcsx_rearmed uses need_fullpath=true, so we just pass the path
retro_game_info game_info{};
game_info.path = romPath;
game_info.data = nullptr;
game_info.size = 0;
game_info.meta = nullptr;
if (!core_load_game_(&game_info)) {
LOGE("retro_load_game failed");
return false;
}
game_loaded_.store(true);
// Get AV info for timing and geometry
retro_system_av_info av_info{};
core_get_system_av_info_(&av_info);
target_fps_ = av_info.timing.fps;
double sample_rate = av_info.timing.sample_rate;
LOGI("Game loaded - %ux%u @ %.1f fps, audio %.0f Hz",
av_info.geometry.base_width, av_info.geometry.base_height,
target_fps_, sample_rate);
// Start audio
audio_engine_.start(static_cast<int32_t>(sample_rate));
return true;
}
// --- Lifecycle ---
void LibretroFrontend::start() {
if (running_.load()) return;
if (!game_loaded_.load()) {
LOGE("Cannot start: no game loaded");
return;
}
running_.store(true);
paused_.store(false);
emu_thread_ = std::thread(&LibretroFrontend::runLoop, this);
LOGI("Emulation started");
}
void LibretroFrontend::pause() {
paused_.store(true);
LOGD("Emulation paused");
}
void LibretroFrontend::resume() {
paused_.store(false);
LOGD("Emulation resumed");
}
void LibretroFrontend::stop() {
running_.store(false);
paused_.store(false);
if (emu_thread_.joinable()) {
emu_thread_.join();
}
// Save SRAM before unloading
if (game_loaded_.load() && core_handle_) {
void* sram = core_get_memory_data_(RETRO_MEMORY_SAVE_RAM);
size_t sram_size = core_get_memory_size_(RETRO_MEMORY_SAVE_RAM);
if (sram && sram_size > 0 && !save_dir_.empty()) {
std::string save_path = save_dir_ + "/game.srm";
std::ofstream out(save_path, std::ios::binary);
if (out) {
out.write(static_cast<const char*>(sram), sram_size);
LOGI("SRAM saved: %zu bytes", sram_size);
}
}
core_unload_game_();
game_loaded_.store(false);
}
audio_engine_.stop();
LOGI("Emulation stopped");
}
void LibretroFrontend::reset() {
if (core_handle_ && game_loaded_.load()) {
core_reset_();
LOGI("Emulation reset");
}
}
void LibretroFrontend::unloadCore() {
if (core_handle_) {
if (game_loaded_.load()) {
core_unload_game_();
game_loaded_.store(false);
}
core_deinit_();
dlclose(core_handle_);
core_handle_ = nullptr;
LOGI("Core unloaded");
}
}
// --- Emulation Loop ---
void LibretroFrontend::runLoop() {
using clock = std::chrono::high_resolution_clock;
auto frame_duration = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::duration<double>(1.0 / target_fps_));
while (running_.load()) {
if (paused_.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(16));
continue;
}
auto frame_start = clock::now();
core_run_();
// Frame pacing
auto elapsed = clock::now() - frame_start;
if (elapsed < frame_duration) {
std::this_thread::sleep_for(frame_duration - elapsed);
}
}
}
// --- Video ---
void LibretroFrontend::setWindow(ANativeWindow* window) {
std::lock_guard<std::mutex> lock(window_mutex_);
if (window_) {
ANativeWindow_release(window_);
}
window_ = window;
if (window_) {
ANativeWindow_acquire(window_);
}
}
void LibretroFrontend::releaseWindow() {
std::lock_guard<std::mutex> lock(window_mutex_);
if (window_) {
ANativeWindow_release(window_);
window_ = nullptr;
}
}
void LibretroFrontend::videoRefreshCallback(const void* data, unsigned width, unsigned height, size_t pitch) {
if (!data) return; // dupe frame
auto& fe = instance();
std::lock_guard<std::mutex> lock(fe.window_mutex_);
if (!fe.window_) return;
int32_t windowFormat;
int bytesPerPixel;
switch (fe.pixel_format_) {
case RETRO_PIXEL_FORMAT_RGB565:
windowFormat = AHARDWAREBUFFER_FORMAT_R5G6B5_UNORM;
bytesPerPixel = 2;
break;
case RETRO_PIXEL_FORMAT_XRGB8888:
windowFormat = AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM;
bytesPerPixel = 4;
break;
case RETRO_PIXEL_FORMAT_0RGB1555:
default:
windowFormat = AHARDWAREBUFFER_FORMAT_R5G6B5_UNORM;
bytesPerPixel = 2;
break;
}
ANativeWindow_setBuffersGeometry(fe.window_, width, height, windowFormat);
ANativeWindow_Buffer buffer;
if (ANativeWindow_lock(fe.window_, &buffer, nullptr) != 0) return;
auto* dst = static_cast<uint8_t*>(buffer.bits);
auto* src = static_cast<const uint8_t*>(data);
size_t dstStride = buffer.stride * bytesPerPixel;
size_t copyWidth = width * bytesPerPixel;
for (unsigned y = 0; y < height; y++) {
memcpy(dst + y * dstStride, src + y * pitch, copyWidth);
}
ANativeWindow_unlockAndPost(fe.window_);
}
// --- Audio ---
size_t LibretroFrontend::audioSampleBatchCallback(const int16_t* data, size_t frames) {
instance().audio_engine_.pushSamples(data, frames);
return frames;
}
void LibretroFrontend::audioSampleCallback(int16_t left, int16_t right) {
int16_t buf[2] = {left, right};
instance().audio_engine_.pushSamples(buf, 1);
}
// --- Input ---
void LibretroFrontend::setButtonState(uint16_t state) {
button_state_.store(state);
}
void LibretroFrontend::setAnalogState(int stick, int16_t x, int16_t y) {
if (stick == 0) {
analog_lx_.store(x);
analog_ly_.store(y);
} else {
analog_rx_.store(x);
analog_ry_.store(y);
}
}
void LibretroFrontend::inputPollCallback() {
// No-op: input state is always current via atomics
}
int16_t LibretroFrontend::inputStateCallback(unsigned port, unsigned device, unsigned index, unsigned id) {
if (port != 0) return 0;
auto& fe = instance();
if (device == RETRO_DEVICE_JOYPAD) {
return (fe.button_state_.load() >> id) & 1;
}
if (device == RETRO_DEVICE_ANALOG) {
if (index == RETRO_DEVICE_INDEX_ANALOG_LEFT) {
return (id == RETRO_DEVICE_ID_ANALOG_X) ? fe.analog_lx_.load() : fe.analog_ly_.load();
} else if (index == RETRO_DEVICE_INDEX_ANALOG_RIGHT) {
return (id == RETRO_DEVICE_ID_ANALOG_X) ? fe.analog_rx_.load() : fe.analog_ry_.load();
}
}
return 0;
}
// --- Environment ---
bool LibretroFrontend::environmentCallback(unsigned cmd, void* data) {
auto& fe = instance();
switch (cmd) {
case RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY: {
auto** dir = static_cast<const char**>(data);
*dir = fe.system_dir_.c_str();
return true;
}
case RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY: {
auto** dir = static_cast<const char**>(data);
*dir = fe.save_dir_.c_str();
return true;
}
case RETRO_ENVIRONMENT_GET_CORE_ASSETS_DIRECTORY: {
auto** dir = static_cast<const char**>(data);
*dir = fe.system_dir_.c_str();
return true;
}
case RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: {
auto* fmt = static_cast<const unsigned*>(data);
fe.pixel_format_ = *fmt;
LOGD("Pixel format set to: %u", *fmt);
return true;
}
case RETRO_ENVIRONMENT_GET_VARIABLE: {
auto* var = static_cast<retro_variable*>(data);
if (var->key) {
auto it = fe.core_options_.find(var->key);
if (it != fe.core_options_.end()) {
var->value = it->second.c_str();
return true;
}
}
var->value = nullptr;
return false;
}
case RETRO_ENVIRONMENT_SET_VARIABLES: {
// Core is telling us what variables it supports
auto* vars = static_cast<const retro_variable*>(data);
while (vars && vars->key) {
// Only set defaults if we haven't already set a value
if (fe.core_options_.find(vars->key) == fe.core_options_.end()) {
// Parse default from the description (format: "Description; val1|val2|val3")
if (vars->value) {
std::string desc(vars->value);
auto semi = desc.find("; ");
if (semi != std::string::npos) {
std::string values = desc.substr(semi + 2);
auto pipe = values.find('|');
std::string first = (pipe != std::string::npos) ?
values.substr(0, pipe) : values;
fe.core_options_[vars->key] = first;
}
}
}
vars++;
}
return true;
}
case RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE: {
auto* updated = static_cast<bool*>(data);
*updated = fe.options_updated_.exchange(false);
return true;
}
case RETRO_ENVIRONMENT_GET_CAN_DUPE: {
auto* can = static_cast<bool*>(data);
*can = true;
return true;
}
case RETRO_ENVIRONMENT_GET_LOG_INTERFACE: {
auto* cb = static_cast<retro_log_callback*>(data);
cb->log = logCallback;
return true;
}
case RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS:
case RETRO_ENVIRONMENT_SET_CONTROLLER_INFO:
case RETRO_ENVIRONMENT_SET_MEMORY_MAPS:
case RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME:
case RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO:
case RETRO_ENVIRONMENT_SET_SERIALIZATION_QUIRKS:
case RETRO_ENVIRONMENT_SET_CORE_OPTIONS:
case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL:
case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2:
case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL:
case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY:
case RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK:
return true; // acknowledge but ignore
case RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION: {
auto* version = static_cast<unsigned*>(data);
*version = 0; // use v0 variables API (simplest)
return true;
}
case RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE: {
auto* bits = static_cast<int*>(data);
*bits = 3; // enable both audio (bit 1) and video (bit 0)
return true;
}
case RETRO_ENVIRONMENT_GET_INPUT_DEVICE_CAPABILITIES: {
auto* caps = static_cast<uint64_t*>(data);
*caps = (1 << RETRO_DEVICE_JOYPAD) | (1 << RETRO_DEVICE_ANALOG);
return true;
}
case RETRO_ENVIRONMENT_SET_GEOMETRY: {
auto* geom = static_cast<const retro_game_geometry*>(data);
LOGD("Geometry changed: %ux%u", geom->base_width, geom->base_height);
return true;
}
case RETRO_ENVIRONMENT_GET_LANGUAGE: {
auto* lang = static_cast<unsigned*>(data);
*lang = 0; // RETRO_LANGUAGE_ENGLISH
return true;
}
default:
LOGD("Unhandled env cmd: %u", cmd);
return false;
}
}
void LibretroFrontend::logCallback(enum retro_log_level level, const char* fmt, ...) {
va_list args;
va_start(args, fmt);
int priority;
switch (level) {
case RETRO_LOG_DEBUG: priority = ANDROID_LOG_DEBUG; break;
case RETRO_LOG_INFO: priority = ANDROID_LOG_INFO; break;
case RETRO_LOG_WARN: priority = ANDROID_LOG_WARN; break;
case RETRO_LOG_ERROR: priority = ANDROID_LOG_ERROR; break;
default: priority = ANDROID_LOG_VERBOSE; break;
}
__android_log_vprint(priority, "RetroCore", fmt, args);
va_end(args);
}
// --- Save States ---
bool LibretroFrontend::saveState(const char* path) {
if (!core_handle_ || !game_loaded_.load()) return false;
size_t size = core_serialize_size_();
if (size == 0) return false;
std::vector<uint8_t> buf(size);
if (!core_serialize_(buf.data(), size)) {
LOGE("retro_serialize failed");
return false;
}
std::ofstream out(path, std::ios::binary);
if (!out) {
LOGE("Failed to open save state file: %s", path);
return false;
}
out.write(reinterpret_cast<const char*>(buf.data()), size);
LOGI("State saved: %zu bytes -> %s", size, path);
return true;
}
bool LibretroFrontend::loadState(const char* path) {
if (!core_handle_ || !game_loaded_.load()) return false;
std::ifstream in(path, std::ios::binary | std::ios::ate);
if (!in) {
LOGE("Failed to open state file: %s", path);
return false;
}
size_t size = in.tellg();
in.seekg(0);
std::vector<uint8_t> buf(size);
in.read(reinterpret_cast<char*>(buf.data()), size);
if (!core_unserialize_(buf.data(), size)) {
LOGE("retro_unserialize failed");
return false;
}
LOGI("State loaded: %zu bytes <- %s", size, path);
return true;
}
// --- Config ---
void LibretroFrontend::setAudioEnabled(bool enabled) {
audio_engine_.setEnabled(enabled);
}
void LibretroFrontend::setFrameSkip(int skip) {
frame_skip_ = skip;
if (skip > 0) {
core_options_["pcsx_rearmed_frameskip_type"] = "auto";
core_options_["pcsx_rearmed_frameskip_threshold"] = "33";
} else {
core_options_["pcsx_rearmed_frameskip_type"] = "disabled";
}
options_updated_.store(true);
}
void LibretroFrontend::setSystemDir(const char* dir) {
system_dir_ = dir;
}
void LibretroFrontend::setSaveDir(const char* dir) {
save_dir_ = dir;
}

View File

@@ -0,0 +1,113 @@
#pragma once
#include "libretro.h"
#include "audio_engine.h"
#include <android/native_window.h>
#include <atomic>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_map>
class LibretroFrontend {
public:
static LibretroFrontend& instance();
bool loadCore(const char* soPath);
bool loadGame(const char* romPath);
void start();
void pause();
void resume();
void stop();
void reset();
void setWindow(ANativeWindow* window);
void releaseWindow();
void setButtonState(uint16_t state);
void setAnalogState(int stick, int16_t x, int16_t y);
bool saveState(const char* path);
bool loadState(const char* path);
void setAudioEnabled(bool enabled);
void setFrameSkip(int skip);
void setSystemDir(const char* dir);
void setSaveDir(const char* dir);
bool isRunning() const { return running_.load(); }
private:
LibretroFrontend() = default;
~LibretroFrontend();
void runLoop();
void unloadCore();
// Libretro callbacks (static because libretro uses C function pointers)
static bool environmentCallback(unsigned cmd, void* data);
static void videoRefreshCallback(const void* data, unsigned width, unsigned height, size_t pitch);
static size_t audioSampleBatchCallback(const int16_t* data, size_t frames);
static void audioSampleCallback(int16_t left, int16_t right);
static void inputPollCallback();
static int16_t inputStateCallback(unsigned port, unsigned device, unsigned index, unsigned id);
static void logCallback(enum retro_log_level level, const char* fmt, ...);
// Core function pointers (resolved via dlsym)
void* core_handle_ = nullptr;
retro_init_t core_init_ = nullptr;
retro_deinit_t core_deinit_ = nullptr;
retro_api_version_t core_api_version_ = nullptr;
retro_get_system_info_t core_get_system_info_ = nullptr;
retro_get_system_av_info_t core_get_system_av_info_ = nullptr;
retro_set_environment_t core_set_environment_ = nullptr;
retro_set_video_refresh_t core_set_video_refresh_ = nullptr;
retro_set_audio_sample_t core_set_audio_sample_ = nullptr;
retro_set_audio_sample_batch_t core_set_audio_sample_batch_ = nullptr;
retro_set_input_poll_t core_set_input_poll_ = nullptr;
retro_set_input_state_t core_set_input_state_ = nullptr;
retro_set_controller_port_device_t core_set_controller_port_device_ = nullptr;
retro_load_game_t core_load_game_ = nullptr;
retro_unload_game_t core_unload_game_ = nullptr;
retro_run_t core_run_ = nullptr;
retro_reset_t core_reset_ = nullptr;
retro_serialize_size_t core_serialize_size_ = nullptr;
retro_serialize_t core_serialize_ = nullptr;
retro_unserialize_t core_unserialize_ = nullptr;
retro_get_memory_data_t core_get_memory_data_ = nullptr;
retro_get_memory_size_t core_get_memory_size_ = nullptr;
// State
std::atomic<bool> running_{false};
std::atomic<bool> paused_{false};
std::atomic<bool> game_loaded_{false};
std::thread emu_thread_;
// Video
ANativeWindow* window_ = nullptr;
std::mutex window_mutex_;
unsigned pixel_format_ = RETRO_PIXEL_FORMAT_RGB565;
// Audio
AudioEngine audio_engine_;
// Input
std::atomic<uint16_t> button_state_{0};
std::atomic<int16_t> analog_lx_{0}, analog_ly_{0};
std::atomic<int16_t> analog_rx_{0}, analog_ry_{0};
// Paths
std::string system_dir_;
std::string save_dir_;
// Frame timing
double target_fps_ = 60.0;
int frame_skip_ = 0;
// Core options
std::unordered_map<std::string, std::string> core_options_;
std::atomic<bool> options_updated_{false};
};

View File

@@ -0,0 +1,89 @@
package com.lazy.emulate
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import android.view.KeyEvent
import android.view.MotionEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.navigation.compose.rememberNavController
import com.lazy.emulate.data.repository.GameRepository
import com.lazy.emulate.input.ControllerManager
import com.lazy.emulate.ui.navigation.EmulateNavGraph
import com.lazy.emulate.ui.theme.EmulateTheme
class MainActivity : ComponentActivity() {
private lateinit var controllerManager: ControllerManager
private lateinit var gameRepository: GameRepository
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestAllFilesPermission()
controllerManager = ControllerManager(this)
gameRepository = GameRepository(this)
setContent {
EmulateTheme {
val windowSizeClass = calculateWindowSizeClass(this)
val navController = rememberNavController()
EmulateNavGraph(
navController = navController,
windowSizeClass = windowSizeClass,
gameRepository = gameRepository,
controllerManager = controllerManager
)
}
}
}
override fun onResume() {
super.onResume()
controllerManager.start()
if (Environment.isExternalStorageManager()) {
gameRepository.scanGameFolders()
}
}
override fun onPause() {
super.onPause()
controllerManager.stop()
}
private fun requestAllFilesPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) {
val intent = Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:$packageName")
)
startActivity(intent)
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (event != null && controllerManager.handleKeyEvent(event)) return true
return super.onKeyDown(keyCode, event)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
if (event != null && controllerManager.handleKeyEvent(event)) return true
return super.onKeyUp(keyCode, event)
}
override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
if (event != null && controllerManager.handleMotionEvent(event)) return true
return super.onGenericMotionEvent(event)
}
}

View File

@@ -0,0 +1,14 @@
package com.lazy.emulate.data.model
import com.lazy.emulate.emulation.ConsoleType
data class Game(
val id: String,
val title: String,
val romPath: String,
val consoleType: ConsoleType,
val coverArtPath: String? = null,
val lastPlayed: Long? = null,
val favorite: Boolean = false,
val playTimeSeconds: Long = 0
)

View File

@@ -0,0 +1,119 @@
package com.lazy.emulate.data.repository
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import com.lazy.emulate.data.model.Game
import com.lazy.emulate.emulation.ConsoleType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.util.UUID
class GameRepository(private val context: Context) {
private val _games = MutableStateFlow<List<Game>>(emptyList())
val games: StateFlow<List<Game>> = _games.asStateFlow()
private val gamesRoot = File(Environment.getExternalStorageDirectory(), "Games")
// Maps folder names to console types
private val folderToConsole = mapOf(
"PS" to ConsoleType.PS1,
"PS1" to ConsoleType.PS1,
"PS2" to ConsoleType.PS2,
"NES" to ConsoleType.NES,
"SNES" to ConsoleType.SNES,
"N64" to ConsoleType.N64,
"Genesis" to ConsoleType.GENESIS,
"GEN" to ConsoleType.GENESIS,
)
fun scanGameFolders() {
if (!gamesRoot.exists()) return
val scanned = mutableListOf<Game>()
val existingPaths = _games.value.map { it.romPath }.toSet()
gamesRoot.listFiles()?.forEach { folder ->
if (!folder.isDirectory) return@forEach
val consoleType = folderToConsole[folder.name.uppercase()]
?: folderToConsole[folder.name]
?: return@forEach
val files = folder.listFiles()?.filter { it.isFile } ?: return@forEach
// For disc-based consoles, prefer .cue files and skip companion .bin files
val cueFiles = files.filter { it.extension.lowercase() == "cue" }
val cueBasenames = cueFiles.map { it.nameWithoutExtension.lowercase() }.toSet()
files.forEach { file ->
if (file.absolutePath in existingPaths) return@forEach
val ext = file.extension.lowercase()
// Skip .bin files that have a matching .cue
if (ext == "bin" && file.nameWithoutExtension.lowercase() in cueBasenames) {
return@forEach
}
if (ext in consoleType.fileExtensions || ext == "zip" || ext == "7z") {
scanned.add(
Game(
id = UUID.randomUUID().toString(),
title = file.nameWithoutExtension,
romPath = file.absolutePath,
consoleType = consoleType
)
)
}
}
}
if (scanned.isNotEmpty()) {
_games.value = _games.value + scanned
}
}
fun addGame(title: String, romPath: String, consoleType: ConsoleType): Game {
val game = Game(
id = UUID.randomUUID().toString(),
title = title,
romPath = romPath,
consoleType = consoleType
)
_games.value = _games.value + game
return game
}
fun addGameFromUri(uri: Uri, consoleType: ConsoleType): Game? {
val fileName = getFileName(uri) ?: return null
val title = fileName.substringBeforeLast(".")
return addGame(title, uri.toString(), consoleType)
}
fun removeGame(gameId: String) {
_games.value = _games.value.filter { it.id != gameId }
}
fun toggleFavorite(gameId: String) {
_games.value = _games.value.map { game ->
if (game.id == gameId) game.copy(favorite = !game.favorite) else game
}
}
fun updateLastPlayed(gameId: String) {
_games.value = _games.value.map { game ->
if (game.id == gameId) game.copy(lastPlayed = System.currentTimeMillis()) else game
}
}
fun getGamesByConsole(consoleType: ConsoleType): List<Game> {
return _games.value.filter { it.consoleType == consoleType }
}
private fun getFileName(uri: Uri): String? {
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
if (cursor.moveToFirst() && nameIndex >= 0) cursor.getString(nameIndex) else null
}
}
}

View File

@@ -0,0 +1,46 @@
package com.lazy.emulate.emulation
enum class ConsoleType(
val displayName: String,
val manufacturer: String,
val fileExtensions: List<String>,
val isImplemented: Boolean = false
) {
PS1(
displayName = "PlayStation",
manufacturer = "Sony",
fileExtensions = listOf("bin", "cue", "iso", "img", "pbp", "chd"),
isImplemented = true
),
NES(
displayName = "Nintendo Entertainment System",
manufacturer = "Nintendo",
fileExtensions = listOf("nes", "unf", "unif")
),
SNES(
displayName = "Super Nintendo",
manufacturer = "Nintendo",
fileExtensions = listOf("sfc", "smc", "fig", "swc")
),
N64(
displayName = "Nintendo 64",
manufacturer = "Nintendo",
fileExtensions = listOf("n64", "z64", "v64", "ndd")
),
GENESIS(
displayName = "Sega Genesis",
manufacturer = "Sega",
fileExtensions = listOf("gen", "md", "smd", "bin")
),
PS2(
displayName = "PlayStation 2",
manufacturer = "Sony",
fileExtensions = listOf("iso", "bin", "chd", "cso")
);
companion object {
fun fromExtension(ext: String): List<ConsoleType> {
return entries.filter { ext.lowercase() in it.fileExtensions }
}
}
}

View File

@@ -0,0 +1,36 @@
package com.lazy.emulate.emulation
import android.content.Context
import com.lazy.emulate.emulation.cores.PlaceholderCore
import com.lazy.emulate.emulation.cores.Ps1Core
object EmulationEngine {
private var activeCore: EmulatorCore? = null
fun getCoreForConsole(context: Context, consoleType: ConsoleType): EmulatorCore {
return when (consoleType) {
ConsoleType.PS1 -> Ps1Core(context)
ConsoleType.NES -> PlaceholderCore(ConsoleType.NES)
ConsoleType.SNES -> PlaceholderCore(ConsoleType.SNES)
ConsoleType.N64 -> PlaceholderCore(ConsoleType.N64)
ConsoleType.GENESIS -> PlaceholderCore(ConsoleType.GENESIS)
ConsoleType.PS2 -> PlaceholderCore(ConsoleType.PS2)
}
}
fun startGame(context: Context, consoleType: ConsoleType, romPath: String): EmulatorCore? {
stopActiveGame()
val core = getCoreForConsole(context, consoleType)
if (!core.loadRom(romPath)) return null
activeCore = core
return core
}
fun getActiveCore(): EmulatorCore? = activeCore
fun stopActiveGame() {
activeCore?.stop()
activeCore = null
}
}

View File

@@ -0,0 +1,39 @@
package com.lazy.emulate.emulation
import android.view.Surface
import com.lazy.emulate.input.GamepadButton
interface EmulatorCore {
val consoleType: ConsoleType
val isRunning: Boolean
fun loadRom(path: String): Boolean
fun start()
fun pause()
fun resume()
fun stop()
fun reset()
fun setSurface(surface: Surface)
fun onButtonPressed(button: GamepadButton)
fun onButtonReleased(button: GamepadButton)
fun onAnalogStick(x: Float, y: Float, stickIndex: Int = 0)
fun saveState(slot: Int): Boolean
fun loadState(slot: Int): Boolean
fun setAudioEnabled(enabled: Boolean)
fun setFrameSkip(skip: Int)
}

View File

@@ -0,0 +1,28 @@
package com.lazy.emulate.emulation
import android.view.Surface
object NativeLibretro {
init {
System.loadLibrary("emulate_native")
}
external fun nativeLoadCore(corePath: String): Boolean
external fun nativeLoadGame(romPath: String): Boolean
external fun nativeStart()
external fun nativePause()
external fun nativeResume()
external fun nativeStop()
external fun nativeReset()
external fun nativeSetSurface(surface: Surface)
external fun nativeReleaseSurface()
external fun nativeSetButtonState(state: Int)
external fun nativeSetAnalogState(stick: Int, x: Short, y: Short)
external fun nativeSaveState(path: String): Boolean
external fun nativeLoadState(path: String): Boolean
external fun nativeSetAudioEnabled(enabled: Boolean)
external fun nativeSetFrameSkip(skip: Int)
external fun nativeSetSystemDir(dir: String)
external fun nativeSetSaveDir(dir: String)
}

View File

@@ -0,0 +1,43 @@
package com.lazy.emulate.emulation.cores
import android.util.Log
import android.view.Surface
import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.emulation.EmulatorCore
import com.lazy.emulate.input.GamepadButton
/**
* Placeholder core used for consoles that are not yet implemented.
* Returns reasonable defaults and logs calls for future implementation.
*/
class PlaceholderCore(override val consoleType: ConsoleType) : EmulatorCore {
override var isRunning: Boolean = false
private set
override fun loadRom(path: String): Boolean {
Log.w(TAG, "${consoleType.displayName} emulation is not yet implemented")
return false
}
override fun start() {
Log.w(TAG, "${consoleType.displayName} emulation is not yet implemented")
}
override fun pause() { isRunning = false }
override fun resume() { isRunning = true }
override fun stop() { isRunning = false }
override fun reset() {}
override fun setSurface(surface: Surface) {}
override fun onButtonPressed(button: GamepadButton) {}
override fun onButtonReleased(button: GamepadButton) {}
override fun onAnalogStick(x: Float, y: Float, stickIndex: Int) {}
override fun saveState(slot: Int): Boolean = false
override fun loadState(slot: Int): Boolean = false
override fun setAudioEnabled(enabled: Boolean) {}
override fun setFrameSkip(skip: Int) {}
companion object {
private const val TAG = "PlaceholderCore"
}
}

View File

@@ -0,0 +1,196 @@
package com.lazy.emulate.emulation.cores
import android.content.Context
import android.util.Log
import android.view.Surface
import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.emulation.EmulatorCore
import com.lazy.emulate.emulation.NativeLibretro
import com.lazy.emulate.input.GamepadButton
import java.io.File
class Ps1Core(private val context: Context) : EmulatorCore {
override val consoleType = ConsoleType.PS1
override var isRunning: Boolean = false
private set
private var buttonState: Int = 0
private val coresDir = File(context.filesDir, "cores")
private val systemDir = File(context.filesDir, "system")
private val savesDir = File(context.filesDir, "saves")
override fun loadRom(path: String): Boolean {
coresDir.mkdirs()
systemDir.mkdirs()
savesDir.mkdirs()
NativeLibretro.nativeSetSystemDir(systemDir.absolutePath)
NativeLibretro.nativeSetSaveDir(savesDir.absolutePath)
// Look for core .so
val corePath = findCorePath()
if (corePath == null) {
Log.e(TAG, "PS1 core .so not found. Place pcsx_rearmed_libretro_android.so in ${coresDir.absolutePath}")
return false
}
// Check BIOS (optional for some cores, but recommended)
val biosFile = File(systemDir, "scph1001.bin")
if (!biosFile.exists()) {
Log.w(TAG, "BIOS not found at ${biosFile.absolutePath} — core will use HLE BIOS")
}
if (!NativeLibretro.nativeLoadCore(corePath)) {
Log.e(TAG, "Failed to load core: $corePath")
return false
}
if (!NativeLibretro.nativeLoadGame(path)) {
Log.e(TAG, "Failed to load game: $path")
return false
}
Log.i(TAG, "Game loaded successfully: $path")
return true
}
override fun start() {
isRunning = true
NativeLibretro.nativeStart()
}
override fun pause() {
isRunning = false
NativeLibretro.nativePause()
}
override fun resume() {
isRunning = true
NativeLibretro.nativeResume()
}
override fun stop() {
isRunning = false
NativeLibretro.nativeStop()
}
override fun reset() {
NativeLibretro.nativeReset()
}
override fun setSurface(surface: Surface) {
NativeLibretro.nativeSetSurface(surface)
}
override fun onButtonPressed(button: GamepadButton) {
val bit = mapButtonToRetroId(button)
if (bit >= 0) {
buttonState = buttonState or (1 shl bit)
NativeLibretro.nativeSetButtonState(buttonState)
}
}
override fun onButtonReleased(button: GamepadButton) {
val bit = mapButtonToRetroId(button)
if (bit >= 0) {
buttonState = buttonState and (1 shl bit).inv()
NativeLibretro.nativeSetButtonState(buttonState)
}
}
override fun onAnalogStick(x: Float, y: Float, stickIndex: Int) {
// Convert -1.0..1.0 float to -32768..32767 int16 range
val ix = (x * 32767).toInt().coerceIn(-32768, 32767).toShort()
val iy = (y * 32767).toInt().coerceIn(-32768, 32767).toShort()
NativeLibretro.nativeSetAnalogState(stickIndex, ix, iy)
}
override fun saveState(slot: Int): Boolean {
val path = File(savesDir, "state_$slot.sav").absolutePath
return NativeLibretro.nativeSaveState(path)
}
override fun loadState(slot: Int): Boolean {
val path = File(savesDir, "state_$slot.sav").absolutePath
return NativeLibretro.nativeLoadState(path)
}
override fun setAudioEnabled(enabled: Boolean) {
NativeLibretro.nativeSetAudioEnabled(enabled)
}
override fun setFrameSkip(skip: Int) {
NativeLibretro.nativeSetFrameSkip(skip)
}
fun releaseSurface() {
NativeLibretro.nativeReleaseSurface()
}
private fun findCorePath(): String? {
val names = listOf(
"pcsx_rearmed_libretro_android.so",
"pcsx_rearmed_libretro.so",
"mednafen_psx_libretro_android.so",
"mednafen_psx_libretro.so",
"swanstation_libretro.so",
)
// Check internal cores dir first
for (name in names) {
val file = File(coresDir, name)
if (file.exists()) return file.absolutePath
}
// Auto-import from /sdcard/Games/cores/ into internal storage
// (dlopen requires the .so to be in app-writable storage, not sdcard)
val sdcardCores = File("/sdcard/Games/cores")
if (sdcardCores.exists()) {
for (name in names) {
val sdFile = File(sdcardCores, name)
if (sdFile.exists()) {
val dest = File(coresDir, name)
try {
sdFile.copyTo(dest, overwrite = true)
dest.setExecutable(true)
Log.i(TAG, "Imported core from sdcard: $name")
return dest.absolutePath
} catch (e: Exception) {
Log.e(TAG, "Failed to import core: ${e.message}")
}
}
}
}
return null
}
private fun mapButtonToRetroId(button: GamepadButton): Int {
return when (button) {
// libretro joypad IDs: B=0 Y=1 SELECT=2 START=3 UP=4 DOWN=5 LEFT=6 RIGHT=7
// A=8 X=9 L=10 R=11 L2=12 R2=13 L3=14 R3=15
// PS1 mapping: Cross=A(8) Circle=B(0) Square=X(9) Triangle=Y(1)
GamepadButton.FACE_BOTTOM -> 8 // Cross -> A
GamepadButton.FACE_RIGHT -> 0 // Circle -> B
GamepadButton.FACE_LEFT -> 9 // Square -> X
GamepadButton.FACE_TOP -> 1 // Triangle -> Y
GamepadButton.DPAD_UP -> 4
GamepadButton.DPAD_DOWN -> 5
GamepadButton.DPAD_LEFT -> 6
GamepadButton.DPAD_RIGHT -> 7
GamepadButton.L1 -> 10
GamepadButton.R1 -> 11
GamepadButton.L2 -> 12
GamepadButton.R2 -> 13
GamepadButton.L3 -> 14
GamepadButton.R3 -> 15
GamepadButton.SELECT -> 2
GamepadButton.START -> 3
else -> -1
}
}
companion object {
private const val TAG = "Ps1Core"
}
}

View File

@@ -0,0 +1,113 @@
package com.lazy.emulate.input
import androidx.compose.ui.geometry.Offset
import com.lazy.emulate.emulation.ConsoleType
data class ButtonPosition(
val button: GamepadButton,
val x: Float, // 0..1 normalized position
val y: Float,
val size: Float = 0.08f // normalized size relative to screen width
)
data class ControllerLayout(
val consoleType: ConsoleType,
val name: String,
val buttons: List<ButtonPosition>,
val dpadPosition: Offset = Offset(0.12f, 0.65f),
val dpadSize: Float = 0.18f,
val leftStickPosition: Offset? = null,
val rightStickPosition: Offset? = null,
val stickSize: Float = 0.14f,
val opacity: Float = 0.6f
) {
companion object {
fun defaultForConsole(consoleType: ConsoleType): ControllerLayout {
return when (consoleType) {
ConsoleType.PS1 -> ps1Default()
ConsoleType.NES -> nesDefault()
ConsoleType.SNES -> snesDefault()
ConsoleType.N64 -> n64Default()
ConsoleType.GENESIS -> genesisDefault()
ConsoleType.PS2 -> ps2Default()
}
}
private fun ps1Default() = ControllerLayout(
consoleType = ConsoleType.PS1,
name = "Default",
buttons = listOf(
ButtonPosition(GamepadButton.FACE_BOTTOM, 0.85f, 0.72f), // Cross
ButtonPosition(GamepadButton.FACE_RIGHT, 0.92f, 0.62f), // Circle
ButtonPosition(GamepadButton.FACE_LEFT, 0.78f, 0.62f), // Square
ButtonPosition(GamepadButton.FACE_TOP, 0.85f, 0.52f), // Triangle
ButtonPosition(GamepadButton.L1, 0.08f, 0.42f, 0.10f),
ButtonPosition(GamepadButton.R1, 0.85f, 0.42f, 0.10f),
ButtonPosition(GamepadButton.L2, 0.08f, 0.35f, 0.10f),
ButtonPosition(GamepadButton.R2, 0.85f, 0.35f, 0.10f),
ButtonPosition(GamepadButton.START, 0.58f, 0.88f, 0.06f),
ButtonPosition(GamepadButton.SELECT, 0.42f, 0.88f, 0.06f),
),
dpadPosition = Offset(0.12f, 0.62f),
leftStickPosition = Offset(0.25f, 0.80f),
rightStickPosition = Offset(0.72f, 0.80f),
)
private fun nesDefault() = ControllerLayout(
consoleType = ConsoleType.NES,
name = "Default",
buttons = listOf(
ButtonPosition(GamepadButton.FACE_BOTTOM, 0.85f, 0.70f), // B
ButtonPosition(GamepadButton.FACE_RIGHT, 0.92f, 0.62f), // A
ButtonPosition(GamepadButton.START, 0.58f, 0.88f, 0.06f),
ButtonPosition(GamepadButton.SELECT, 0.42f, 0.88f, 0.06f),
),
dpadPosition = Offset(0.12f, 0.65f),
)
private fun snesDefault() = ControllerLayout(
consoleType = ConsoleType.SNES,
name = "Default",
buttons = listOf(
ButtonPosition(GamepadButton.FACE_BOTTOM, 0.85f, 0.72f), // B
ButtonPosition(GamepadButton.FACE_RIGHT, 0.92f, 0.62f), // A
ButtonPosition(GamepadButton.FACE_LEFT, 0.78f, 0.62f), // Y
ButtonPosition(GamepadButton.FACE_TOP, 0.85f, 0.52f), // X
ButtonPosition(GamepadButton.L1, 0.08f, 0.42f, 0.10f),
ButtonPosition(GamepadButton.R1, 0.85f, 0.42f, 0.10f),
ButtonPosition(GamepadButton.START, 0.58f, 0.88f, 0.06f),
ButtonPosition(GamepadButton.SELECT, 0.42f, 0.88f, 0.06f),
),
dpadPosition = Offset(0.12f, 0.65f),
)
private fun n64Default() = ControllerLayout(
consoleType = ConsoleType.N64,
name = "Default",
buttons = listOf(
ButtonPosition(GamepadButton.FACE_BOTTOM, 0.85f, 0.72f), // A
ButtonPosition(GamepadButton.FACE_RIGHT, 0.92f, 0.62f), // B
ButtonPosition(GamepadButton.L1, 0.08f, 0.42f, 0.10f), // L
ButtonPosition(GamepadButton.R1, 0.85f, 0.42f, 0.10f), // R
ButtonPosition(GamepadButton.L2, 0.25f, 0.50f, 0.10f), // Z
ButtonPosition(GamepadButton.START, 0.50f, 0.88f, 0.06f),
),
dpadPosition = Offset(0.12f, 0.65f),
leftStickPosition = Offset(0.25f, 0.75f),
)
private fun genesisDefault() = ControllerLayout(
consoleType = ConsoleType.GENESIS,
name = "Default",
buttons = listOf(
ButtonPosition(GamepadButton.FACE_BOTTOM, 0.78f, 0.72f), // A
ButtonPosition(GamepadButton.FACE_RIGHT, 0.86f, 0.65f), // B
ButtonPosition(GamepadButton.FACE_LEFT, 0.94f, 0.58f), // C
ButtonPosition(GamepadButton.START, 0.50f, 0.88f, 0.06f),
),
dpadPosition = Offset(0.12f, 0.65f),
)
private fun ps2Default() = ps1Default().copy(consoleType = ConsoleType.PS2, name = "Default")
}
}

View File

@@ -0,0 +1,148 @@
package com.lazy.emulate.input
import android.content.Context
import android.hardware.input.InputManager
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class ControllerManager(context: Context) : InputManager.InputDeviceListener {
private val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager
private val _connectedControllers = MutableStateFlow<List<GameController>>(emptyList())
val connectedControllers: StateFlow<List<GameController>> = _connectedControllers.asStateFlow()
private val _activeController = MutableStateFlow<GameController?>(null)
val activeController: StateFlow<GameController?> = _activeController.asStateFlow()
var onButtonEvent: ((GamepadButton, Boolean) -> Unit)? = null
var onAnalogEvent: ((Float, Float, Int) -> Unit)? = null
fun start() {
inputManager.registerInputDeviceListener(this, null)
refreshControllers()
}
fun stop() {
inputManager.unregisterInputDeviceListener(this)
}
private fun refreshControllers() {
val controllers = InputDevice.getDeviceIds()
.toList()
.mapNotNull { InputDevice.getDevice(it) }
.filter { isGameController(it) }
.map { device ->
GameController(
deviceId = device.id,
name = device.name,
vendorId = device.vendorId,
productId = device.productId,
isExternal = device.isExternal
)
}
_connectedControllers.value = controllers
if (_activeController.value == null && controllers.isNotEmpty()) {
_activeController.value = controllers.first()
}
}
fun handleKeyEvent(event: KeyEvent): Boolean {
if (!isGameControllerDevice(event.device ?: return false)) return false
val button = mapKeyToButton(event.keyCode) ?: return false
when (event.action) {
KeyEvent.ACTION_DOWN -> onButtonEvent?.invoke(button, true)
KeyEvent.ACTION_UP -> onButtonEvent?.invoke(button, false)
}
return true
}
fun handleMotionEvent(event: MotionEvent): Boolean {
if (!isGameControllerDevice(event.device ?: return false)) return false
// Left stick
val lx = event.getAxisValue(MotionEvent.AXIS_X)
val ly = event.getAxisValue(MotionEvent.AXIS_Y)
if (lx != 0f || ly != 0f) {
onAnalogEvent?.invoke(lx, ly, 0)
}
// Right stick
val rx = event.getAxisValue(MotionEvent.AXIS_Z)
val ry = event.getAxisValue(MotionEvent.AXIS_RZ)
if (rx != 0f || ry != 0f) {
onAnalogEvent?.invoke(rx, ry, 1)
}
// D-pad via axes (some controllers report dpad as axis)
val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X)
val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
handleDpadAxis(hatX, hatY)
return true
}
private fun handleDpadAxis(hatX: Float, hatY: Float) {
onButtonEvent?.invoke(GamepadButton.DPAD_LEFT, hatX < -0.5f)
onButtonEvent?.invoke(GamepadButton.DPAD_RIGHT, hatX > 0.5f)
onButtonEvent?.invoke(GamepadButton.DPAD_UP, hatY < -0.5f)
onButtonEvent?.invoke(GamepadButton.DPAD_DOWN, hatY > 0.5f)
}
fun setActiveController(controller: GameController) {
_activeController.value = controller
}
private fun mapKeyToButton(keyCode: Int): GamepadButton? {
return when (keyCode) {
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER -> GamepadButton.FACE_BOTTOM
KeyEvent.KEYCODE_BUTTON_B -> GamepadButton.FACE_RIGHT
KeyEvent.KEYCODE_BUTTON_X -> GamepadButton.FACE_LEFT
KeyEvent.KEYCODE_BUTTON_Y -> GamepadButton.FACE_TOP
KeyEvent.KEYCODE_BUTTON_L1 -> GamepadButton.L1
KeyEvent.KEYCODE_BUTTON_R1 -> GamepadButton.R1
KeyEvent.KEYCODE_BUTTON_L2 -> GamepadButton.L2
KeyEvent.KEYCODE_BUTTON_R2 -> GamepadButton.R2
KeyEvent.KEYCODE_BUTTON_THUMBL -> GamepadButton.L3
KeyEvent.KEYCODE_BUTTON_THUMBR -> GamepadButton.R3
KeyEvent.KEYCODE_BUTTON_START -> GamepadButton.START
KeyEvent.KEYCODE_BUTTON_SELECT -> GamepadButton.SELECT
KeyEvent.KEYCODE_DPAD_UP -> GamepadButton.DPAD_UP
KeyEvent.KEYCODE_DPAD_DOWN -> GamepadButton.DPAD_DOWN
KeyEvent.KEYCODE_DPAD_LEFT -> GamepadButton.DPAD_LEFT
KeyEvent.KEYCODE_DPAD_RIGHT -> GamepadButton.DPAD_RIGHT
else -> null
}
}
private fun isGameController(device: InputDevice): Boolean {
val sources = device.sources
return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD ||
(sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
}
private fun isGameControllerDevice(device: InputDevice): Boolean = isGameController(device)
// InputManager.InputDeviceListener
override fun onInputDeviceAdded(deviceId: Int) = refreshControllers()
override fun onInputDeviceRemoved(deviceId: Int) {
if (_activeController.value?.deviceId == deviceId) {
_activeController.value = null
}
refreshControllers()
}
override fun onInputDeviceChanged(deviceId: Int) = refreshControllers()
}
data class GameController(
val deviceId: Int,
val name: String,
val vendorId: Int,
val productId: Int,
val isExternal: Boolean
)

View File

@@ -0,0 +1,78 @@
package com.lazy.emulate.input
enum class GamepadButton {
// D-Pad
DPAD_UP,
DPAD_DOWN,
DPAD_LEFT,
DPAD_RIGHT,
// Face buttons (universal naming, mapped per console)
FACE_BOTTOM, // PS: Cross, Nintendo: B, Genesis: A
FACE_RIGHT, // PS: Circle, Nintendo: A, Genesis: B
FACE_LEFT, // PS: Square, Nintendo: Y, Genesis: X
FACE_TOP, // PS: Triangle, Nintendo: X, Genesis: Y
// Shoulders
L1,
R1,
L2,
R2,
// Sticks
L3,
R3,
// Meta
START,
SELECT,
// Genesis-specific
MODE, // Genesis mode button
Z_BUTTON, // Genesis 6-button Z
C_BUTTON; // Genesis 6-button C
companion object {
val PS1_BUTTONS = listOf(
DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT,
FACE_BOTTOM, FACE_RIGHT, FACE_LEFT, FACE_TOP,
L1, R1, L2, R2, L3, R3, START, SELECT
)
val NES_BUTTONS = listOf(
DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT,
FACE_BOTTOM, FACE_RIGHT, START, SELECT
)
val SNES_BUTTONS = listOf(
DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT,
FACE_BOTTOM, FACE_RIGHT, FACE_LEFT, FACE_TOP,
L1, R1, START, SELECT
)
val N64_BUTTONS = listOf(
DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT,
FACE_BOTTOM, FACE_RIGHT, FACE_LEFT, FACE_TOP,
L1, R1, L2, START
)
val GENESIS_BUTTONS = listOf(
DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT,
FACE_BOTTOM, FACE_RIGHT, FACE_LEFT,
START, MODE, Z_BUTTON, C_BUTTON
)
val PS2_BUTTONS = PS1_BUTTONS
fun buttonsForConsole(consoleType: com.lazy.emulate.emulation.ConsoleType): List<GamepadButton> {
return when (consoleType) {
com.lazy.emulate.emulation.ConsoleType.PS1 -> PS1_BUTTONS
com.lazy.emulate.emulation.ConsoleType.NES -> NES_BUTTONS
com.lazy.emulate.emulation.ConsoleType.SNES -> SNES_BUTTONS
com.lazy.emulate.emulation.ConsoleType.N64 -> N64_BUTTONS
com.lazy.emulate.emulation.ConsoleType.GENESIS -> GENESIS_BUTTONS
com.lazy.emulate.emulation.ConsoleType.PS2 -> PS2_BUTTONS
}
}
}
}

View File

@@ -0,0 +1,25 @@
package com.lazy.emulate.ui.adaptive
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
enum class LayoutType {
COMPACT, // Phone portrait
MEDIUM, // Foldable inner / small tablet
EXPANDED // Tablet landscape
}
fun WindowSizeClass.toLayoutType(): LayoutType {
return when (widthSizeClass) {
WindowWidthSizeClass.Compact -> LayoutType.COMPACT
WindowWidthSizeClass.Medium -> LayoutType.MEDIUM
WindowWidthSizeClass.Expanded -> LayoutType.EXPANDED
else -> LayoutType.COMPACT
}
}
fun LayoutType.gridColumns(): Int = when (this) {
LayoutType.COMPACT -> 2
LayoutType.MEDIUM -> 3
LayoutType.EXPANDED -> 4
}

View File

@@ -0,0 +1,41 @@
package com.lazy.emulate.ui.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.lazy.emulate.emulation.ConsoleType
@Composable
fun ConsoleFilter(
selectedConsole: ConsoleType?,
onConsoleSelected: (ConsoleType?) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = selectedConsole == null,
onClick = { onConsoleSelected(null) },
label = { Text("All") }
)
ConsoleType.entries.forEach { console ->
FilterChip(
selected = selectedConsole == console,
onClick = { onConsoleSelected(console) },
label = { Text(console.displayName) },
enabled = console.isImplemented
)
}
}
}

View File

@@ -0,0 +1,104 @@
package com.lazy.emulate.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.lazy.emulate.data.model.Game
@Composable
fun GameCard(
game: Game,
onClick: () -> Unit,
onFavoriteToggle: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column {
// Cover art placeholder
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.SportsEsports,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.padding(32.dp)
)
// Console badge
Text(
text = game.consoleType.displayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.background(
MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
RoundedCornerShape(4.dp)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
// Title and favorite
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Text(
text = game.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(end = 36.dp)
)
IconButton(
onClick = onFavoriteToggle,
modifier = Modifier.align(Alignment.CenterEnd)
) {
Icon(
imageVector = if (game.favorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = if (game.favorite) "Remove from favorites" else "Add to favorites",
tint = if (game.favorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}

View File

@@ -0,0 +1,286 @@
package com.lazy.emulate.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.lazy.emulate.input.ButtonPosition
import com.lazy.emulate.input.ControllerLayout
import com.lazy.emulate.input.GamepadButton
import kotlin.math.atan2
import kotlin.math.sqrt
@Composable
fun TouchControllerOverlay(
layout: ControllerLayout,
onButtonPressed: (GamepadButton) -> Unit,
onButtonReleased: (GamepadButton) -> Unit,
onAnalogStick: (Float, Float, Int) -> Unit,
modifier: Modifier = Modifier
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val screenWidth = constraints.maxWidth.toFloat()
val screenHeight = constraints.maxHeight.toFloat()
// D-Pad
DPad(
centerX = layout.dpadPosition.x * screenWidth,
centerY = layout.dpadPosition.y * screenHeight,
size = layout.dpadSize * screenWidth,
opacity = layout.opacity,
onDirectionPressed = { onButtonPressed(it) },
onDirectionReleased = { onButtonReleased(it) }
)
// Analog sticks
layout.leftStickPosition?.let { pos ->
AnalogStick(
centerX = pos.x * screenWidth,
centerY = pos.y * screenHeight,
size = layout.stickSize * screenWidth,
opacity = layout.opacity,
onStickMoved = { x, y -> onAnalogStick(x, y, 0) }
)
}
layout.rightStickPosition?.let { pos ->
AnalogStick(
centerX = pos.x * screenWidth,
centerY = pos.y * screenHeight,
size = layout.stickSize * screenWidth,
opacity = layout.opacity,
onStickMoved = { x, y -> onAnalogStick(x, y, 1) }
)
}
// Face buttons and shoulders
layout.buttons.forEach { buttonPos ->
TouchButton(
buttonPosition = buttonPos,
screenWidth = screenWidth,
screenHeight = screenHeight,
opacity = layout.opacity,
onPressed = { onButtonPressed(buttonPos.button) },
onReleased = { onButtonReleased(buttonPos.button) }
)
}
}
}
@Composable
private fun TouchButton(
buttonPosition: ButtonPosition,
screenWidth: Float,
screenHeight: Float,
opacity: Float,
onPressed: () -> Unit,
onReleased: () -> Unit
) {
val sizePx = buttonPosition.size * screenWidth
val density = LocalDensity.current
val sizeDp = with(density) { sizePx.toDp() }
val xDp = with(density) { (buttonPosition.x * screenWidth - sizePx / 2).toDp() }
val yDp = with(density) { (buttonPosition.y * screenHeight - sizePx / 2).toDp() }
var isPressed by remember { mutableStateOf(false) }
val color = if (isPressed) Color.White.copy(alpha = opacity * 0.8f) else Color.White.copy(alpha = opacity * 0.4f)
Box(
modifier = Modifier
.offset(x = xDp, y = yDp)
.size(sizeDp)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val wasPressed = isPressed
isPressed = event.changes.any { it.pressed }
if (isPressed && !wasPressed) onPressed()
if (!isPressed && wasPressed) onReleased()
}
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = color,
radius = size.minDimension / 2,
style = Stroke(width = 3.dp.toPx())
)
if (isPressed) {
drawCircle(
color = color.copy(alpha = color.alpha * 0.3f),
radius = size.minDimension / 2
)
}
}
}
}
@Composable
private fun DPad(
centerX: Float,
centerY: Float,
size: Float,
opacity: Float,
onDirectionPressed: (GamepadButton) -> Unit,
onDirectionReleased: (GamepadButton) -> Unit
) {
val density = LocalDensity.current
val sizeDp = with(density) { size.toDp() }
val xDp = with(density) { (centerX - size / 2).toDp() }
val yDp = with(density) { (centerY - size / 2).toDp() }
// Track which directions are currently held
var activeDirections by remember { mutableStateOf(emptySet<GamepadButton>()) }
val color = Color.White.copy(alpha = opacity * 0.4f)
Box(
modifier = Modifier
.offset(x = xDp, y = yDp)
.size(sizeDp)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull() ?: continue
change.consume()
val newDirections = mutableSetOf<GamepadButton>()
if (change.pressed) {
val dx = change.position.x - this@pointerInput.size.width / 2f
val dy = change.position.y - this@pointerInput.size.height / 2f
val dist = sqrt(dx * dx + dy * dy)
val deadZone = this@pointerInput.size.width * 0.12f
if (dist > deadZone) {
// Use thresholds instead of exclusive angles to allow diagonals
val nx = dx / dist
val ny = dy / dist
val threshold = 0.38f // ~22 degrees — allows diagonal input
if (nx > threshold) newDirections.add(GamepadButton.DPAD_RIGHT)
if (nx < -threshold) newDirections.add(GamepadButton.DPAD_LEFT)
if (ny > threshold) newDirections.add(GamepadButton.DPAD_DOWN)
if (ny < -threshold) newDirections.add(GamepadButton.DPAD_UP)
}
}
// Release directions no longer held
for (dir in activeDirections - newDirections) {
onDirectionReleased(dir)
}
// Press newly held directions
for (dir in newDirections - activeDirections) {
onDirectionPressed(dir)
}
activeDirections = newDirections
}
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val armWidth = size * 0.3f
val center = Offset(size / 2, size / 2)
// Horizontal arm
drawRoundRect(
color = if (GamepadButton.DPAD_LEFT in activeDirections || GamepadButton.DPAD_RIGHT in activeDirections)
color.copy(alpha = color.alpha * 1.5f) else color,
topLeft = Offset(0f, center.y - armWidth / 2),
size = androidx.compose.ui.geometry.Size(size, armWidth),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(8f, 8f),
style = Stroke(width = 2.dp.toPx())
)
// Vertical arm
drawRoundRect(
color = if (GamepadButton.DPAD_UP in activeDirections || GamepadButton.DPAD_DOWN in activeDirections)
color.copy(alpha = color.alpha * 1.5f) else color,
topLeft = Offset(center.x - armWidth / 2, 0f),
size = androidx.compose.ui.geometry.Size(armWidth, size),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(8f, 8f),
style = Stroke(width = 2.dp.toPx())
)
}
}
}
@Composable
private fun AnalogStick(
centerX: Float,
centerY: Float,
size: Float,
opacity: Float,
onStickMoved: (Float, Float) -> Unit
) {
val density = LocalDensity.current
val sizeDp = with(density) { size.toDp() }
val xDp = with(density) { (centerX - size / 2).toDp() }
val yDp = with(density) { (centerY - size / 2).toDp() }
var stickOffset by remember { mutableStateOf(Offset.Zero) }
val maxRadius = size / 2 * 0.7f
val color = Color.White.copy(alpha = opacity * 0.4f)
Box(
modifier = Modifier
.offset(x = xDp, y = yDp)
.size(sizeDp)
.pointerInput(Unit) {
detectDragGestures(
onDragEnd = {
stickOffset = Offset.Zero
onStickMoved(0f, 0f)
},
onDragCancel = {
stickOffset = Offset.Zero
onStickMoved(0f, 0f)
}
) { _, dragAmount ->
val newOffset = stickOffset + dragAmount
val dist = sqrt(newOffset.x * newOffset.x + newOffset.y * newOffset.y)
stickOffset = if (dist > maxRadius) {
newOffset * (maxRadius / dist)
} else {
newOffset
}
onStickMoved(stickOffset.x / maxRadius, stickOffset.y / maxRadius)
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val center = Offset(size / 2, size / 2)
// Outer ring
drawCircle(
color = color,
radius = size / 2 - 4,
center = center,
style = Stroke(width = 2.dp.toPx())
)
// Inner stick
drawCircle(
color = color.copy(alpha = color.alpha * 1.5f),
radius = size * 0.15f,
center = center + stickOffset
)
}
}
}

View File

@@ -0,0 +1,77 @@
package com.lazy.emulate.ui.navigation
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.lazy.emulate.data.repository.GameRepository
import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.input.ControllerManager
import com.lazy.emulate.ui.screens.controller.ControllerLayoutScreen
import com.lazy.emulate.ui.screens.game.GameScreen
import com.lazy.emulate.ui.screens.home.HomeScreen
import com.lazy.emulate.ui.screens.settings.SettingsScreen
@Composable
fun EmulateNavGraph(
navController: NavHostController,
windowSizeClass: WindowSizeClass,
gameRepository: GameRepository,
controllerManager: ControllerManager
) {
NavHost(
navController = navController,
startDestination = Screen.Home.route
) {
composable(Screen.Home.route) {
HomeScreen(
windowSizeClass = windowSizeClass,
gameRepository = gameRepository,
onGameSelected = { game ->
navController.navigate(Screen.Game.createRoute(game.id))
},
onSettingsClick = {
navController.navigate(Screen.Settings.route)
},
onControllerLayoutClick = { consoleType ->
navController.navigate(Screen.ControllerLayout.createRoute(consoleType.name))
}
)
}
composable(Screen.Settings.route) {
SettingsScreen(
controllerManager = controllerManager,
onBack = { navController.popBackStack() }
)
}
composable(
route = Screen.ControllerLayout.route,
arguments = listOf(navArgument("consoleType") { type = NavType.StringType })
) { backStackEntry ->
val consoleTypeName = backStackEntry.arguments?.getString("consoleType") ?: return@composable
val consoleType = ConsoleType.valueOf(consoleTypeName)
ControllerLayoutScreen(
consoleType = consoleType,
onBack = { navController.popBackStack() }
)
}
composable(
route = Screen.Game.route,
arguments = listOf(navArgument("gameId") { type = NavType.StringType })
) { backStackEntry ->
val gameId = backStackEntry.arguments?.getString("gameId") ?: return@composable
val game = gameRepository.games.value.find { it.id == gameId } ?: return@composable
GameScreen(
game = game,
controllerManager = controllerManager,
onBack = { navController.popBackStack() }
)
}
}
}

View File

@@ -0,0 +1,12 @@
package com.lazy.emulate.ui.navigation
sealed class Screen(val route: String) {
data object Home : Screen("home")
data object Settings : Screen("settings")
data object ControllerLayout : Screen("controller_layout/{consoleType}") {
fun createRoute(consoleType: String) = "controller_layout/$consoleType"
}
data object Game : Screen("game/{gameId}") {
fun createRoute(gameId: String) = "game/$gameId"
}
}

View File

@@ -0,0 +1,228 @@
package com.lazy.emulate.ui.screens.controller
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.input.ButtonPosition
import com.lazy.emulate.input.ControllerLayout
import com.lazy.emulate.input.GamepadButton
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ControllerLayoutScreen(
consoleType: ConsoleType,
onBack: () -> Unit
) {
var layout by remember { mutableStateOf(ControllerLayout.defaultForConsole(consoleType)) }
var opacity by remember { mutableFloatStateOf(layout.opacity) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("${consoleType.displayName} Layout") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = {
layout = ControllerLayout.defaultForConsole(consoleType)
opacity = layout.opacity
}) {
Icon(Icons.Default.RestartAlt, contentDescription = "Reset Layout")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Preview area
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color.Black)
) {
val screenWidth = constraints.maxWidth.toFloat()
val screenHeight = constraints.maxHeight.toFloat()
val density = LocalDensity.current
// Draggable buttons
layout.buttons.forEach { buttonPos ->
DraggableButton(
buttonPosition = buttonPos,
screenWidth = screenWidth,
screenHeight = screenHeight,
opacity = opacity,
onPositionChanged = { newX, newY ->
layout = layout.copy(
buttons = layout.buttons.map {
if (it.button == buttonPos.button) {
it.copy(x = newX / screenWidth, y = newY / screenHeight)
} else it
}
)
}
)
}
// Info overlay
Text(
text = "Drag buttons to reposition",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.5f),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(16.dp)
)
}
// Controls
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text("Opacity", style = MaterialTheme.typography.bodyLarge)
Slider(
value = opacity,
onValueChange = {
opacity = it
layout = layout.copy(opacity = it)
},
valueRange = 0.1f..1.0f
)
Spacer(modifier = Modifier.height(12.dp))
Row {
OutlinedButton(
onClick = {
layout = ControllerLayout.defaultForConsole(consoleType)
opacity = layout.opacity
},
modifier = Modifier.weight(1f)
) {
Text("Reset")
}
Spacer(modifier = Modifier.width(12.dp))
Button(
onClick = {
// TODO: Save layout to DataStore
onBack()
},
modifier = Modifier.weight(1f)
) {
Text("Save")
}
}
}
}
}
}
@Composable
private fun DraggableButton(
buttonPosition: ButtonPosition,
screenWidth: Float,
screenHeight: Float,
opacity: Float,
onPositionChanged: (Float, Float) -> Unit
) {
val density = LocalDensity.current
val sizePx = buttonPosition.size * screenWidth
var offsetX by remember { mutableFloatStateOf(buttonPosition.x * screenWidth) }
var offsetY by remember { mutableFloatStateOf(buttonPosition.y * screenHeight) }
val sizeDp = with(density) { sizePx.toDp() }
Box(
modifier = Modifier
.offset { IntOffset((offsetX - sizePx / 2).roundToInt(), (offsetY - sizePx / 2).roundToInt()) }
.size(sizeDp)
.border(2.dp, Color.White.copy(alpha = opacity), CircleShape)
.background(Color.White.copy(alpha = opacity * 0.2f), CircleShape)
.pointerInput(Unit) {
detectDragGestures { _, dragAmount ->
offsetX = (offsetX + dragAmount.x).coerceIn(0f, screenWidth)
offsetY = (offsetY + dragAmount.y).coerceIn(0f, screenHeight)
onPositionChanged(offsetX, offsetY)
}
},
contentAlignment = Alignment.Center
) {
Text(
text = buttonPosition.button.toLabel(),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = opacity)
)
}
}
private fun GamepadButton.toLabel(): String = when (this) {
GamepadButton.FACE_BOTTOM -> "X"
GamepadButton.FACE_RIGHT -> "O"
GamepadButton.FACE_LEFT -> "[]"
GamepadButton.FACE_TOP -> "/\\"
GamepadButton.DPAD_UP -> "Up"
GamepadButton.DPAD_DOWN -> "Dn"
GamepadButton.DPAD_LEFT -> "Lt"
GamepadButton.DPAD_RIGHT -> "Rt"
GamepadButton.L1 -> "L1"
GamepadButton.R1 -> "R1"
GamepadButton.L2 -> "L2"
GamepadButton.R2 -> "R2"
GamepadButton.L3 -> "L3"
GamepadButton.R3 -> "R3"
GamepadButton.START -> "ST"
GamepadButton.SELECT -> "SL"
GamepadButton.MODE -> "MD"
GamepadButton.Z_BUTTON -> "Z"
GamepadButton.C_BUTTON -> "C"
}

View File

@@ -0,0 +1,181 @@
package com.lazy.emulate.ui.screens.game
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.lazy.emulate.data.model.Game
import com.lazy.emulate.emulation.EmulationEngine
import com.lazy.emulate.emulation.EmulatorCore
import com.lazy.emulate.emulation.cores.Ps1Core
import com.lazy.emulate.input.ControllerLayout
import com.lazy.emulate.input.ControllerManager
import com.lazy.emulate.ui.components.TouchControllerOverlay
@Composable
fun GameScreen(
game: Game,
controllerManager: ControllerManager,
onBack: () -> Unit
) {
val context = LocalContext.current
var core by remember { mutableStateOf<EmulatorCore?>(null) }
var isPaused by remember { mutableStateOf(false) }
var loadError by remember { mutableStateOf<String?>(null) }
val activeController by controllerManager.activeController.collectAsState()
val showTouchControls = activeController == null
val layout = remember(game.consoleType) {
ControllerLayout.defaultForConsole(game.consoleType)
}
DisposableEffect(game.id) {
val loaded = EmulationEngine.startGame(context, game.consoleType, game.romPath)
if (loaded != null) {
core = loaded
controllerManager.onButtonEvent = { button, pressed ->
if (pressed) loaded.onButtonPressed(button) else loaded.onButtonReleased(button)
}
controllerManager.onAnalogEvent = { x, y, stick ->
loaded.onAnalogStick(x, y, stick)
}
} else {
loadError = "Failed to load game. Check that the core .so and BIOS are installed."
}
onDispose {
(core as? Ps1Core)?.releaseSurface()
EmulationEngine.stopActiveGame()
controllerManager.onButtonEvent = null
controllerManager.onAnalogEvent = null
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
// Emulation surface
if (core != null) {
AndroidView(
factory = { ctx ->
SurfaceView(ctx).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
core?.setSurface(holder.surface)
core?.start()
}
override fun surfaceChanged(
holder: SurfaceHolder, format: Int, width: Int, height: Int
) {
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
core?.pause()
(core as? Ps1Core)?.releaseSurface()
}
})
}
},
modifier = Modifier.fillMaxSize()
)
}
// Error or placeholder message
val msg = loadError
?: if (core == null || !game.consoleType.isImplemented)
"${game.consoleType.displayName} emulation coming soon"
else null
if (msg != null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = msg,
style = MaterialTheme.typography.headlineMedium,
color = Color.White.copy(alpha = 0.7f)
)
}
}
// Touch controls (hidden when BT controller connected)
if (showTouchControls && core != null) {
TouchControllerOverlay(
layout = layout,
onButtonPressed = { core?.onButtonPressed(it) },
onButtonReleased = { core?.onButtonReleased(it) },
onAnalogStick = { x, y, stick -> core?.onAnalogStick(x, y, stick) }
)
}
// HUD controls
IconButton(
onClick = onBack,
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White.copy(alpha = 0.7f)
)
}
IconButton(
onClick = {
isPaused = !isPaused
if (isPaused) core?.pause() else core?.resume()
},
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
) {
Icon(
if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
contentDescription = if (isPaused) "Resume" else "Pause",
tint = Color.White.copy(alpha = 0.7f)
)
}
IconButton(
onClick = { core?.saveState(0) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 8.dp, end = 48.dp)
) {
Icon(
Icons.Default.Save,
contentDescription = "Save State",
tint = Color.White.copy(alpha = 0.7f)
)
}
}
}

View File

@@ -0,0 +1,164 @@
package com.lazy.emulate.ui.screens.home
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Gamepad
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.lazy.emulate.data.model.Game
import com.lazy.emulate.data.repository.GameRepository
import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.ui.adaptive.gridColumns
import com.lazy.emulate.ui.adaptive.toLayoutType
import com.lazy.emulate.ui.components.ConsoleFilter
import com.lazy.emulate.ui.components.GameCard
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
windowSizeClass: WindowSizeClass,
gameRepository: GameRepository,
onGameSelected: (Game) -> Unit,
onSettingsClick: () -> Unit,
onControllerLayoutClick: (ConsoleType) -> Unit
) {
val games by gameRepository.games.collectAsState()
var selectedConsole by remember { mutableStateOf<ConsoleType?>(null) }
val layoutType = windowSizeClass.toLayoutType()
val filteredGames = if (selectedConsole != null) {
games.filter { it.consoleType == selectedConsole }
} else {
games
}
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
uri?.let {
// Default to PS1 for now, could show a picker dialog
gameRepository.addGameFromUri(it, ConsoleType.PS1)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Emulate") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
),
actions = {
IconButton(onClick = { onControllerLayoutClick(selectedConsole ?: ConsoleType.PS1) }) {
Icon(Icons.Default.Gamepad, contentDescription = "Controller Layout")
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
filePickerLauncher.launch(arrayOf("*/*"))
}
) {
Icon(Icons.Default.Add, contentDescription = "Add Game")
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
ConsoleFilter(
selectedConsole = selectedConsole,
onConsoleSelected = { selectedConsole = it }
)
Spacer(modifier = Modifier.height(8.dp))
if (filteredGames.isEmpty()) {
EmptyLibrary()
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(layoutType.gridColumns()),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(filteredGames, key = { it.id }) { game ->
GameCard(
game = game,
onClick = { onGameSelected(game) },
onFavoriteToggle = { gameRepository.toggleFavorite(game.id) }
)
}
}
}
}
}
}
@Composable
private fun EmptyLibrary() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.SportsEsports,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.padding(16.dp)
)
Text(
text = "No games in library",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Tap + to add your backup ROMs",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}

View File

@@ -0,0 +1,195 @@
package com.lazy.emulate.ui.screens.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.BluetoothConnected
import androidx.compose.material.icons.filled.Gamepad
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.lazy.emulate.input.ControllerManager
import com.lazy.emulate.input.GameController
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
controllerManager: ControllerManager,
onBack: () -> Unit
) {
val controllers by controllerManager.connectedControllers.collectAsState()
val activeController by controllerManager.activeController.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Controllers section
Text(
text = "Controllers",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp))
if (controllers.isEmpty()) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Bluetooth,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "No controllers connected",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "Pair a Bluetooth controller in system settings",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
Card(modifier = Modifier.fillMaxWidth()) {
controllers.forEachIndexed { index, controller ->
ControllerItem(
controller = controller,
isActive = controller.deviceId == activeController?.deviceId,
onClick = { controllerManager.setActiveController(controller) }
)
if (index < controllers.lastIndex) {
HorizontalDivider()
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Emulation section
Text(
text = "Emulation",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Audio", style = MaterialTheme.typography.bodyLarge)
Text(
"Enabled",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider()
Column(modifier = Modifier.padding(16.dp)) {
Text("Frame Skip", style = MaterialTheme.typography.bodyLarge)
Text(
"Auto",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// About
Text(
text = "About",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Emulate v1.0", style = MaterialTheme.typography.bodyLarge)
Text(
"For personal backup games only",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun ControllerItem(
controller: GameController,
isActive: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isActive) Icons.Default.BluetoothConnected else Icons.Default.Gamepad,
contentDescription = null,
tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = controller.name,
style = MaterialTheme.typography.bodyLarge,
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
Text(
text = if (isActive) "Active" else if (controller.isExternal) "Bluetooth" else "Built-in",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,20 @@
package com.lazy.emulate.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFBB86FC)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6200EE)
val PurpleGrey40 = Color(0xFF625B71)
val Pink40 = Color(0xFF7D5260)
// Console brand colors
val NintendoRed = Color(0xFFE60012)
val PlayStationBlue = Color(0xFF003087)
val SegaBlue = Color(0xFF0060A8)
val SurfaceDark = Color(0xFF121212)
val SurfaceVariantDark = Color(0xFF1E1E2E)
val CardDark = Color(0xFF2A2A3C)

View File

@@ -0,0 +1,47 @@
package com.lazy.emulate.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
surface = SurfaceDark,
surfaceVariant = SurfaceVariantDark,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
)
@Composable
fun EmulateTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,52 @@
package com.lazy.emulate.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp
)
)

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,6 @@
<resources>
<style name="Theme.Emulate" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Emulate</string>
</resources>

View File

@@ -0,0 +1,6 @@
<resources>
<style name="Theme.Emulate" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>