commit 39e3f8b7c008d77948a445ad98171a5ec07b47d9 Author: Matt Hills Date: Wed Apr 8 20:34:37 2026 -0400 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..4f6fe81 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,87 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.lazy.emulate" + compileSdk = 36 + + defaultConfig { + applicationId = "com.lazy.emulate" + minSdk = 30 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { + abiFilters += listOf("arm64-v8a") + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1+" + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + jvmToolchain(17) + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + + // Compose + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons) + implementation(libs.activity.compose) + implementation(libs.navigation.compose) + debugImplementation(libs.compose.ui.tooling) + + // Lifecycle + implementation(libs.lifecycle.runtime.compose) + implementation(libs.lifecycle.viewmodel.compose) + + // Window Size Classes + implementation(libs.material3.wsc) + + // Coroutines + implementation(libs.coroutines.android) + + // DataStore + implementation(libs.datastore.prefs) + + // Testing + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/lazy/emulate/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lazy/emulate/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..19b5b4b --- /dev/null +++ b/app/src/androidTest/java/com/lazy/emulate/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.lazy.emulate + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.lazy.emulate", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a465706 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..56086ad --- /dev/null +++ b/app/src/main/cpp/CMakeLists.txt @@ -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) diff --git a/app/src/main/cpp/audio_engine.cpp b/app/src/main/cpp/audio_engine.cpp new file mode 100644 index 0000000..3057f02 --- /dev/null +++ b/app/src/main/cpp/audio_engine.cpp @@ -0,0 +1,114 @@ +#include "audio_engine.h" +#include +#include +#include + +#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(userData); + auto* output = static_cast(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(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); +} diff --git a/app/src/main/cpp/audio_engine.h b/app/src/main/cpp/audio_engine.h new file mode 100644 index 0000000..f196df9 --- /dev/null +++ b/app/src/main/cpp/audio_engine.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +// 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 read_pos_{0}; + std::atomic 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 enabled_{true}; + + static aaudio_data_callback_result_t dataCallback( + AAudioStream* stream, void* userData, void* audioData, int32_t numFrames); +}; diff --git a/app/src/main/cpp/jni_bridge.cpp b/app/src/main/cpp/jni_bridge.cpp new file mode 100644 index 0000000..c44e231 --- /dev/null +++ b/app/src/main/cpp/jni_bridge.cpp @@ -0,0 +1,119 @@ +#include "libretro_frontend.h" + +#include +#include +#include + +#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(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" diff --git a/app/src/main/cpp/libretro.h b/app/src/main/cpp/libretro.h new file mode 100644 index 0000000..df1ebd8 --- /dev/null +++ b/app/src/main/cpp/libretro.h @@ -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 +#include + +#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__ */ diff --git a/app/src/main/cpp/libretro_frontend.cpp b/app/src/main/cpp/libretro_frontend.cpp new file mode 100644 index 0000000..87b7e8e --- /dev/null +++ b/app/src/main/cpp/libretro_frontend.cpp @@ -0,0 +1,556 @@ +#include "libretro_frontend.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#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(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(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::duration(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 lock(window_mutex_); + if (window_) { + ANativeWindow_release(window_); + } + window_ = window; + if (window_) { + ANativeWindow_acquire(window_); + } +} + +void LibretroFrontend::releaseWindow() { + std::lock_guard 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 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(buffer.bits); + auto* src = static_cast(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(data); + *dir = fe.system_dir_.c_str(); + return true; + } + case RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY: { + auto** dir = static_cast(data); + *dir = fe.save_dir_.c_str(); + return true; + } + case RETRO_ENVIRONMENT_GET_CORE_ASSETS_DIRECTORY: { + auto** dir = static_cast(data); + *dir = fe.system_dir_.c_str(); + return true; + } + case RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: { + auto* fmt = static_cast(data); + fe.pixel_format_ = *fmt; + LOGD("Pixel format set to: %u", *fmt); + return true; + } + case RETRO_ENVIRONMENT_GET_VARIABLE: { + auto* var = static_cast(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(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(data); + *updated = fe.options_updated_.exchange(false); + return true; + } + case RETRO_ENVIRONMENT_GET_CAN_DUPE: { + auto* can = static_cast(data); + *can = true; + return true; + } + case RETRO_ENVIRONMENT_GET_LOG_INTERFACE: { + auto* cb = static_cast(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(data); + *version = 0; // use v0 variables API (simplest) + return true; + } + case RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE: { + auto* bits = static_cast(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(data); + *caps = (1 << RETRO_DEVICE_JOYPAD) | (1 << RETRO_DEVICE_ANALOG); + return true; + } + case RETRO_ENVIRONMENT_SET_GEOMETRY: { + auto* geom = static_cast(data); + LOGD("Geometry changed: %ux%u", geom->base_width, geom->base_height); + return true; + } + case RETRO_ENVIRONMENT_GET_LANGUAGE: { + auto* lang = static_cast(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 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(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 buf(size); + in.read(reinterpret_cast(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; +} diff --git a/app/src/main/cpp/libretro_frontend.h b/app/src/main/cpp/libretro_frontend.h new file mode 100644 index 0000000..bc6d5d8 --- /dev/null +++ b/app/src/main/cpp/libretro_frontend.h @@ -0,0 +1,113 @@ +#pragma once + +#include "libretro.h" +#include "audio_engine.h" + +#include +#include +#include +#include +#include +#include + +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 running_{false}; + std::atomic paused_{false}; + std::atomic 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 button_state_{0}; + std::atomic analog_lx_{0}, analog_ly_{0}; + std::atomic 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 core_options_; + std::atomic options_updated_{false}; +}; diff --git a/app/src/main/java/com/lazy/emulate/MainActivity.kt b/app/src/main/java/com/lazy/emulate/MainActivity.kt new file mode 100644 index 0000000..29b2a6a --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/MainActivity.kt @@ -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) + } +} diff --git a/app/src/main/java/com/lazy/emulate/data/model/Game.kt b/app/src/main/java/com/lazy/emulate/data/model/Game.kt new file mode 100644 index 0000000..c37c33f --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/data/model/Game.kt @@ -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 +) diff --git a/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt new file mode 100644 index 0000000..a177b4f --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt @@ -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>(emptyList()) + val games: StateFlow> = _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() + 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 { + 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 + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt b/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt new file mode 100644 index 0000000..65896c9 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt @@ -0,0 +1,46 @@ +package com.lazy.emulate.emulation + +enum class ConsoleType( + val displayName: String, + val manufacturer: String, + val fileExtensions: List, + 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 { + return entries.filter { ext.lowercase() in it.fileExtensions } + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt b/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt new file mode 100644 index 0000000..6662b9d --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt @@ -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 + } +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt b/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt new file mode 100644 index 0000000..4c7d993 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt @@ -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) +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt b/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt new file mode 100644 index 0000000..10a20e0 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt @@ -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) +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt b/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt new file mode 100644 index 0000000..7e1fa73 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt @@ -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" + } +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt b/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt new file mode 100644 index 0000000..f5a3287 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt @@ -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" + } +} diff --git a/app/src/main/java/com/lazy/emulate/input/ControllerLayout.kt b/app/src/main/java/com/lazy/emulate/input/ControllerLayout.kt new file mode 100644 index 0000000..55891d3 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/input/ControllerLayout.kt @@ -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, + 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") + } +} diff --git a/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt b/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt new file mode 100644 index 0000000..cb4a2c9 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt @@ -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>(emptyList()) + val connectedControllers: StateFlow> = _connectedControllers.asStateFlow() + + private val _activeController = MutableStateFlow(null) + val activeController: StateFlow = _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 +) diff --git a/app/src/main/java/com/lazy/emulate/input/GamepadButton.kt b/app/src/main/java/com/lazy/emulate/input/GamepadButton.kt new file mode 100644 index 0000000..5fbf494 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/input/GamepadButton.kt @@ -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 { + 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 + } + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/adaptive/AdaptiveLayout.kt b/app/src/main/java/com/lazy/emulate/ui/adaptive/AdaptiveLayout.kt new file mode 100644 index 0000000..64ba7e3 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/adaptive/AdaptiveLayout.kt @@ -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 +} diff --git a/app/src/main/java/com/lazy/emulate/ui/components/ConsoleFilter.kt b/app/src/main/java/com/lazy/emulate/ui/components/ConsoleFilter.kt new file mode 100644 index 0000000..3d727cf --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/components/ConsoleFilter.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt b/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt new file mode 100644 index 0000000..534739f --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt @@ -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 + ) + } + } + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/components/TouchControllerOverlay.kt b/app/src/main/java/com/lazy/emulate/ui/components/TouchControllerOverlay.kt new file mode 100644 index 0000000..8795798 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/components/TouchControllerOverlay.kt @@ -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()) } + 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() + + 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 + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt b/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..7fee3c4 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt @@ -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() } + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt b/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt new file mode 100644 index 0000000..15fbc3f --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt @@ -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" + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerLayoutScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerLayoutScreen.kt new file mode 100644 index 0000000..9c444ed --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerLayoutScreen.kt @@ -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" +} diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt new file mode 100644 index 0000000..6e6924b --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt @@ -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(null) } + var isPaused by remember { mutableStateOf(false) } + var loadError by remember { mutableStateOf(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) + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt new file mode 100644 index 0000000..77e6025 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt @@ -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(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) + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..f9346aa --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/com/lazy/emulate/ui/theme/Color.kt b/app/src/main/java/com/lazy/emulate/ui/theme/Color.kt new file mode 100644 index 0000000..e412abf --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/theme/Color.kt @@ -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) diff --git a/app/src/main/java/com/lazy/emulate/ui/theme/Theme.kt b/app/src/main/java/com/lazy/emulate/ui/theme/Theme.kt new file mode 100644 index 0000000..9f59eba --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/lazy/emulate/ui/theme/Type.kt b/app/src/main/java/com/lazy/emulate/ui/theme/Type.kt new file mode 100644 index 0000000..d5528b9 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/theme/Type.kt @@ -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 + ) +) diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..94369a7 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8692ae0 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Emulate + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1cff752 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..4df9255 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/lazy/emulate/ExampleUnitTest.kt b/app/src/test/java/com/lazy/emulate/ExampleUnitTest.kt new file mode 100644 index 0000000..db2a893 --- /dev/null +++ b/app/src/test/java/com/lazy/emulate/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.lazy.emulate + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..8dacf8c --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.compose.compiler) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..20e2a01 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..4a32fc4 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,53 @@ +[versions] +agp = "9.0.1" +kotlin = "2.1.20" +coreKtx = "1.16.0" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +appcompat = "1.7.1" +material = "1.13.0" +composeBom = "2025.04.01" +activityCompose = "1.10.1" +navigationCompose = "2.9.0" +lifecycle = "2.9.0" +coroutines = "1.10.2" +windowSizeClass = "1.3.2" +datastorePrefs = "1.1.4" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } + +# Compose +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +# Lifecycle +lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } + +# Window Size +material3-wsc = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "windowSizeClass" } + +# Coroutines +coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } + +# DataStore +datastore-prefs = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePrefs" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a47f7f6 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +#Wed Apr 08 19:39:50 EDT 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..adc56d5 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Emulate" +include(":app")