diff --git a/Cargo.lock b/Cargo.lock index 173f75c..b91b676 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "itoa" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "540654e97a3f4470a492cd30ff187bc95d89557a903a2bbf112e2fae98104ef2" + [[package]] name = "jni" version = "0.21.1" @@ -82,6 +88,10 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "mes-core" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] [[package]] name = "mes-jni" @@ -124,6 +134,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + [[package]] name = "same-file" version = "1.0.6" @@ -133,6 +149,38 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "serde" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.215" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "syn" version = "2.0.87" diff --git a/README.md b/README.md index f9c901f..288ef39 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,24 @@ # Mes -A decent NES emulator built for the Web using Rust and WebAssembly. Try it [now](https://luckasranarison.github.io/mes/). +A decent multiplatform NES emulator built using Rust. Try it [now](https://luckasranarison.github.io/mes/) in your browser. + +## Contents +- [Supported platforms](#supported-platforms) +- [Features](#features) +- [Mappers](#mappers) +- [Build](#build) +- [Resources](#resources) + +## Supported platforms + +- [x] Web +- [x] Android +- [ ] Desktop +- [ ] Embedded (ESP32) ## Features -- Almost cycle accurate emulation - Supports [iNES 1.0](https://www.nesdev.org/wiki/INES) file format - Supports basic [mappers](#mappers) - Fairly decent audio quality @@ -21,12 +34,59 @@ A decent NES emulator built for the Web using Rust and WebAssembly. Try it [now] - [UXROM](https://nesdir.github.io/mapper2.html) (2) - [CNROM](https://nesdir.github.io/mapper2.html) (3) -## TODOs +## Build + +> [!IMPORTANT] +> The Rust [toolchain](https://rustup.rs/) is required to build the main library. + +### Web + +![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) +![Vite](https://img.shields.io/badge/vite-%23646CFF.svg?style=for-the-badge&logo=vite&logoColor=white) +![WebAssembly](https://img.shields.io/badge/WebAssembly-654FF0?logo=webassembly&logoColor=fff&style=for-the-badge) + +**Requirements**: + +- [NodeJS](https://nodejs.org/en) +- [wasmpack](https://rustwasm.github.io/wasm-pack/) + +**Scripts**: + +```bash +npm run wasm # build the WASM artifacts using wasmpack +npm run dev # run the dev server +npm run build # build the website +``` + +### Android + +![Kotlin](https://img.shields.io/badge/kotlin-%237F52FF.svg?style=for-the-badge&logo=kotlin&logoColor=white) + +**Requirements**: + +- [Android studio](https://developer.android.com/studio) +- [NDK](https://developer.android.com/ndk) +- `aarch64-linux-android` and `x86_64-linux-android` Rust targets + +**Setup**: + +Edit your global cargo config in `~/.cargo/cargo.toml` and use linkers from NDK: + +```toml +[target.aarch64-linux-android] +linker = "your-ndk-pah/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang" + +[target.x86_64-linux-android] +linker = "your-ndk-pah/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang" +``` + +**Gradle scripts**: -- [ ] Settings interface (controllers, palette, ...) -- [ ] Versions for other platforms (mobile, desktop, ...) +- `buildRustArm64`: Build the shared library for arm64 +- `buildRustx86_64`: Build the shared library for x86_64 +- `buildRs`: Runs both -## References +## Resources This project wouldn't have been possible without the help of the following ressources: diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..cc17c58 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,17 @@ +*.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 +*.so +app/release diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/android/.idea/.name b/android/.idea/.name new file mode 100644 index 0000000..3e35f38 --- /dev/null +++ b/android/.idea/.name @@ -0,0 +1 @@ +Mes \ No newline at end of file diff --git a/android/.idea/appInsightsSettings.xml b/android/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/android/.idea/appInsightsSettings.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/codeStyles/Project.xml b/android/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/android/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/codeStyles/codeStyleConfig.xml b/android/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/android/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/deploymentTargetSelector.xml b/android/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca6ec97 --- /dev/null +++ b/android/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml new file mode 100644 index 0000000..7b3006b --- /dev/null +++ b/android/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/android/.idea/inspectionProfiles/Project_Default.xml b/android/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..cde3e19 --- /dev/null +++ b/android/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,57 @@ + + + + \ No newline at end of file diff --git a/android/.idea/kotlinc.xml b/android/.idea/kotlinc.xml new file mode 100644 index 0000000..6d0ee1c --- /dev/null +++ b/android/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/.idea/migrations.xml b/android/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/android/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/android/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/android/.idea/runConfigurations.xml b/android/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/android/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/vcs.xml b/android/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..a6655c8 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,116 @@ +import com.android.build.gradle.internal.tasks.factory.dependsOn +import java.nio.file.Path + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.aboutlibraries) +} + +android { + namespace = "dev.luckasranarison.mes" + compileSdk = 34 + + defaultConfig { + applicationId = "dev.luckasranarison.mes" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + buildFeatures { + compose = true + } + + project.tasks.preBuild.dependsOn("buildRust") +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.runtime.livedata) + implementation(libs.androidx.documentfile) + implementation(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.aboutlibraries.compose.m3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} + +tasks.register("buildRust") { + group = "build setup" + description = "Builds the Rust shared library used with JNI" + + dependsOn("buildRustx86_64") + dependsOn("buildRustArm64") +} + +tasks.register("buildRustArm64") { + group = "build setup" + description = "Builds the Rust shared library for arm64" + + buildRustLibrary("aarch64", "arm64-v8a") +} + +tasks.register("buildRustx86_64") { + group = "build setup" + description = "Builds the Rust shared library for x86_64" + + buildRustLibrary("x86_64") +} + +fun buildRustLibrary(rustArch: String, directoryArch: String? = null) { + val scriptFile = project.buildscript.sourceFile!! + val parentPath = Path.of(scriptFile.parent!!) + val libPath = parentPath.resolve("src/main/jniLibs") + val buildPath = parentPath.resolve("../../target/$rustArch-linux-android/release/libmes_jni.so") + val archLibPath = libPath.resolve(directoryArch ?: rustArch) + + exec { + commandLine( + "cargo", + "build", + "-p=mes-jni", + "--target=$rustArch-linux-android", + "--release" + ) + commandLine("cp", buildPath, archLibPath) + } +} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/android/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/android/app/src/androidTest/java/dev/luckasranarison/mes/ExampleInstrumentedTest.kt b/android/app/src/androidTest/java/dev/luckasranarison/mes/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..3f0564d --- /dev/null +++ b/android/app/src/androidTest/java/dev/luckasranarison/mes/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package dev.luckasranarison.mes + +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.example.helloworld", appContext.packageName) + } +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d4530b3 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..5908a5c Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/java/dev/luckasranarison/mes/Application.kt b/android/app/src/main/java/dev/luckasranarison/mes/Application.kt new file mode 100644 index 0000000..3887fa4 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/Application.kt @@ -0,0 +1,58 @@ +package dev.luckasranarison.mes + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import dev.luckasranarison.mes.anim.Animations +import dev.luckasranarison.mes.ui.emulator.Emulator +import dev.luckasranarison.mes.vm.EmulatorViewModel +import dev.luckasranarison.mes.ui.home.Home +import dev.luckasranarison.mes.ui.info.Info +import dev.luckasranarison.mes.ui.license.License +import dev.luckasranarison.mes.ui.settings.Settings + +data object Routes { + const val HOME = "home" + const val EMULATOR = "emulator" + const val SETTINGS = "settings" + const val INFO = "info" + const val LICENSE = "licenses" +} + +@Composable +fun App(viewModel: EmulatorViewModel) { + val navController = rememberNavController() + val isShortcutLaunch by remember { viewModel.isShortcutLaunch } + + NavHost( + navController = navController, + startDestination = if (isShortcutLaunch) Routes.EMULATOR else Routes.HOME, + enterTransition = { Animations.EnterTransition }, + exitTransition = { Animations.ExitTransition }, + popEnterTransition = { Animations.PopEnterTransition }, + popExitTransition = { Animations.PopExitTransition } + ) { + composable(Routes.HOME) { + Home(viewModel = viewModel, controller = navController) + } + composable(Routes.EMULATOR, popExitTransition = { ExitTransition.None }) { + Emulator(viewModel = viewModel, controller = navController) + } + composable(Routes.INFO) { + Info(controller = navController) + } + composable(Routes.LICENSE) { + License(controller = navController) + } + composable(Routes.SETTINGS, enterTransition = { + if (initialState.destination.route == Routes.EMULATOR) EnterTransition.None else null + }) { + Settings(viewModel = viewModel, onExit = navController::popBackStack) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/MainActivity.kt b/android/app/src/main/java/dev/luckasranarison/mes/MainActivity.kt new file mode 100644 index 0000000..1bd23e7 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/MainActivity.kt @@ -0,0 +1,61 @@ +package dev.luckasranarison.mes + +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import dev.luckasranarison.mes.lib.Rust +import dev.luckasranarison.mes.ui.theme.MesTheme +import dev.luckasranarison.mes.vm.EmulatorViewModel + +object Activities { + val GET_CONTENT = ActivityResultContracts.GetContent() + val GET_DIRECTORY = ActivityResultContracts.OpenDocumentTree() +} + +class MainActivity : ComponentActivity() { + private val viewModel: EmulatorViewModel by viewModels { EmulatorViewModel.Factory } + + companion object { + init { + System.loadLibrary("mes_jni") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + Rust.setPanicHook() // Redirects Rust panics output to Log before crashing + handleShortcutLaunch() + enableEdgeToEdge() + + setContent { + MesTheme { + App(viewModel = viewModel) + } + } + } + + override fun onPause() { + super.onPause() + viewModel.pauseEmulation() + } + + override fun onResume() { + super.onResume() + viewModel.startEmulation() + } + + private fun handleShortcutLaunch() { + val extras = intent.extras + val path = extras?.getString("path") + + if (path !== null) { + viewModel.loadRomFromFile(this, Uri.parse(path)) + viewModel.setShortcutLaunch() + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/anim/Animation.kt b/android/app/src/main/java/dev/luckasranarison/mes/anim/Animation.kt new file mode 100644 index 0000000..85ab5f8 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/anim/Animation.kt @@ -0,0 +1,37 @@ +package dev.luckasranarison.mes.anim + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.ui.unit.IntOffset + +object Animations { + private val animationSpec: FiniteAnimationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ) + + val EnterTransition: EnterTransition = slideInHorizontally( + initialOffsetX = { it }, + animationSpec = animationSpec + ) + + val ExitTransition: ExitTransition = slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = animationSpec + ) + + val PopEnterTransition: EnterTransition = slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = animationSpec + ) + + val PopExitTransition: ExitTransition = slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = animationSpec + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/data/SettingsRepository.kt b/android/app/src/main/java/dev/luckasranarison/mes/data/SettingsRepository.kt new file mode 100644 index 0000000..8060574 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/data/SettingsRepository.kt @@ -0,0 +1,37 @@ +package dev.luckasranarison.mes.data + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.byteArrayPreferencesKey +import kotlinx.coroutines.flow.map + +class SettingsRepository(private val dataStore: DataStore) { + object Keys { + val ROM_DIR = stringPreferencesKey("rom_dir") + val ENABLE_APU = booleanPreferencesKey("enable_apu") + val COLOR_PALETTE = byteArrayPreferencesKey("color_palette") + } + + suspend fun setRomDirectory(dir: String) { + dataStore.edit { pref -> pref[Keys.ROM_DIR] = dir } + } + + suspend fun toggleApuState() { + dataStore.edit { pref -> pref[Keys.ENABLE_APU] = !(pref[Keys.ENABLE_APU] ?: true) } + } + + suspend fun setColorPalette(palette: ByteArray?) { + if (palette != null) { + dataStore.edit { pref -> pref[Keys.COLOR_PALETTE] = palette } + } else { + dataStore.edit { pref -> pref.remove(Keys.COLOR_PALETTE) } + } + } + + fun getRomDirectory() = dataStore.data.map { pref -> pref[Keys.ROM_DIR] } + fun getApuState() = dataStore.data.map { pref -> pref[Keys.ENABLE_APU] } + fun getColorPalette() = dataStore.data.map { pref -> pref[Keys.COLOR_PALETTE] } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/data/Store.kt b/android/app/src/main/java/dev/luckasranarison/mes/data/Store.kt new file mode 100644 index 0000000..7055031 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/data/Store.kt @@ -0,0 +1,6 @@ +package dev.luckasranarison.mes.data + +import android.content.Context +import androidx.datastore.preferences.preferencesDataStore + +val Context.dataStore by preferencesDataStore(name = "settings") \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/data/Types.kt b/android/app/src/main/java/dev/luckasranarison/mes/data/Types.kt new file mode 100644 index 0000000..5d23170 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/data/Types.kt @@ -0,0 +1,43 @@ +package dev.luckasranarison.mes.data + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class RomHeader( + @SerialName("prg_rom_pages") val prgRomPages: Byte, + @SerialName("chr_rom_pages") val chrRomPages: Byte, + @SerialName("prg_ram_pages") val prgRamPages: Byte, + val mirroring: String, + val battery: Boolean, + val trainer: Boolean, + val mapper: Short +) + +data class RomFile( + val name: String, + val uri: Uri, + val size: Long, + val header: RomHeader +) { + private val attributesRegex = Regex("\\((.*?)\\)|\\[(.*?)]") + + constructor(file: DocumentFile, metadata: String) : this( + name = file.name ?: "Unknown", + uri = file.uri, + size = file.length(), + header = Json.decodeFromString(metadata) + ) + + fun getAttributes() = attributesRegex + .findAll(name) + .mapNotNull { it.groups[1]?.value } + .toList() + + fun baseName() = name + .replace(".nes", "", ignoreCase = true) + .replace(attributesRegex, "") +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/extra/Shortcut.kt b/android/app/src/main/java/dev/luckasranarison/mes/extra/Shortcut.kt new file mode 100644 index 0000000..1575fb8 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/extra/Shortcut.kt @@ -0,0 +1,23 @@ +package dev.luckasranarison.mes.extra + +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import androidx.core.content.ContextCompat.getSystemService +import dev.luckasranarison.mes.MainActivity +import dev.luckasranarison.mes.data.RomFile + +fun createShortcut(ctx: Context, rom: RomFile) { + val shortcutManager = getSystemService(ctx, ShortcutManager::class.java) + + val intent = Intent(Intent.ACTION_VIEW, rom.uri, ctx, MainActivity::class.java) + intent.putExtra("path", rom.uri.toString()) + + val shortcut = ShortcutInfo.Builder(ctx, rom.uri.toString()) + .setShortLabel(rom.baseName()) + .setIntent(intent) + .build() + + shortcutManager?.requestPinShortcut(shortcut, null) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/lib/Audio.kt b/android/app/src/main/java/dev/luckasranarison/mes/lib/Audio.kt new file mode 100644 index 0000000..8f7b50c --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/lib/Audio.kt @@ -0,0 +1,28 @@ +package dev.luckasranarison.mes.lib + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack + +fun createAudioTrack(): AudioTrack { + val sampleRate = 44100 + val channelConfig = AudioFormat.CHANNEL_OUT_MONO + val audioFormat = AudioFormat.ENCODING_PCM_FLOAT + val minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat) + + return AudioTrack( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(), + AudioFormat.Builder() + .setSampleRate(sampleRate) + .setChannelMask(channelConfig) + .setEncoding(audioFormat) + .build(), + minBufferSize, + AudioTrack.MODE_STREAM, + AudioManager.AUDIO_SESSION_ID_GENERATE + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/lib/Controller.kt b/android/app/src/main/java/dev/luckasranarison/mes/lib/Controller.kt new file mode 100644 index 0000000..b3d916a --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/lib/Controller.kt @@ -0,0 +1,17 @@ +package dev.luckasranarison.mes.lib + +import kotlin.experimental.and +import kotlin.experimental.inv +import kotlin.experimental.or + +class Controller(private var value: Byte = 0b0000_0000) { + fun update(button: Button, state: Boolean): Controller { + val bits = (1 shl button.ordinal).toByte() + val value = if (state) value or bits else value and bits.inv() + return Controller(value) + } + + fun state(): Byte = value +} + +enum class Button { Right, Left, Down, Up, Start, Select, B, A } \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/lib/Nes.kt b/android/app/src/main/java/dev/luckasranarison/mes/lib/Nes.kt new file mode 100644 index 0000000..08933bd --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/lib/Nes.kt @@ -0,0 +1,71 @@ +package dev.luckasranarison.mes.lib + +import android.util.Log + +typealias NesPtr = Long + +object Nes { + external fun init(): NesPtr + external fun reset(nes: NesPtr) + external fun setCartridge(nes: NesPtr, bytes: ByteArray) + external fun stepFrame(nes: NesPtr) + external fun stepVBlank(nes: NesPtr) + external fun fillAudioBuffer(nes: NesPtr, buffer: FloatArray): Int + external fun clearAudioBuffer(nes: NesPtr) + external fun fillFrameBuffer(nes: NesPtr, buffer: IntArray, palette: ByteArray?) + external fun setControllerState(nes: NesPtr, id: Long, state: Byte) + external fun free(nes: NesPtr) + external fun serializeRomHeader(rom: ByteArray): String +} + +const val AUDIO_BUFFER_SIZE = 1024 +const val SCREEN_WIDTH = 256 +const val SCREEN_HEIGHT = 240 +const val FRAME_BUFFER_SIZE = SCREEN_WIDTH * SCREEN_HEIGHT +const val COLOR_PALETTE_SIZE = 192 +const val FRAME_DURATION = 1_000_000_000 / 60 +const val PRG_ROM_PAGE_SIZE = 16384; +const val PRG_RAM_SIZE = 8192; +const val CHR_ROM_PAGE_SIZE = 8192; +val INES_ASCII = byteArrayOf(0x4E, 0x45, 0x53, 0x1A) + +class NesObject { + private val ptr = Nes.init() + private val audioBuffer = FloatArray(AUDIO_BUFFER_SIZE) + private val frameBuffer = IntArray(FRAME_BUFFER_SIZE) + private var colorPalette: ByteArray? = null + + init { + Log.i("mes", "Emulator instance was created") + } + + fun reset() = Nes.reset(ptr) + fun setCartridge(bytes: ByteArray) = Nes.setCartridge(ptr, bytes) + fun stepFrame() = Nes.stepFrame(ptr) + fun stepVBlank() = Nes.stepVBlank(ptr) + fun clearAudioBuffer() = Nes.clearAudioBuffer(ptr) + fun setControllerState(id: Long, state: Byte) = Nes.setControllerState(ptr, id, state) + + fun updateFrameBuffer(): IntArray { + Nes.fillFrameBuffer(ptr, frameBuffer, colorPalette) + return frameBuffer + } + + fun updateAudioBuffer(): Pair { + val length = Nes.fillAudioBuffer(ptr, audioBuffer) + return Pair(audioBuffer, length) + } + + fun setColorPalette(palette: ByteArray?) { + if (palette == null || palette.size == COLOR_PALETTE_SIZE) { + colorPalette = palette + } else { + throw Exception("Invalid color palette") + } + } + + fun free() { + Nes.free(ptr) + Log.i("mes", "Emulator instance was destroyed") + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/lib/Rust.kt b/android/app/src/main/java/dev/luckasranarison/mes/lib/Rust.kt new file mode 100644 index 0000000..6c56564 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/lib/Rust.kt @@ -0,0 +1,5 @@ +package dev.luckasranarison.mes.lib + +object Rust { + external fun setPanicHook() +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorBackHandler.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorBackHandler.kt new file mode 100644 index 0000000..d803dd2 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorBackHandler.kt @@ -0,0 +1,52 @@ +package dev.luckasranarison.mes.ui.emulator + +import android.app.Activity +import androidx.activity.compose.BackHandler +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavHostController + +@Composable +fun EmulatorBackHandler( + controller: NavHostController, + pauseEmulation: () -> Unit, + resumeEmulation: () -> Unit, + isShortcutLaunch: Boolean, +) { + val ctx = LocalContext.current as Activity + var showExitDialog by remember { mutableStateOf(false) } + + BackHandler { showExitDialog = true } + + LaunchedEffect(showExitDialog) { + if (showExitDialog) { + pauseEmulation() + } else { + resumeEmulation() + } + } + + if (showExitDialog) { + AlertDialog( + onDismissRequest = { showExitDialog = false }, + title = { Text(text = "Confirm to exit") }, + text = { Text(text = "Are you sure to stop the emulation?") }, + confirmButton = { + TextButton(onClick = { + when (isShortcutLaunch) { + true -> ctx.finishAffinity() + else -> controller.popBackStack() + } + }) { + Text(text = "Confirm") + } + }, + dismissButton = { + TextButton(onClick = { showExitDialog = false }) { + Text(text = "Cancel") + } + }, + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorScreen.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorScreen.kt new file mode 100644 index 0000000..37520e3 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorScreen.kt @@ -0,0 +1,51 @@ +package dev.luckasranarison.mes.ui.emulator + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavHostController +import dev.luckasranarison.mes.lib.createAudioTrack +import dev.luckasranarison.mes.ui.gamepad.GamePadLayout +import dev.luckasranarison.mes.vm.EmulatorViewModel + +@Composable +fun Emulator(viewModel: EmulatorViewModel, controller: NavHostController) { + val ctx = LocalContext.current + val emulatorView = remember { EmulatorView(ctx) } + val audioTrack = remember { createAudioTrack() } + val isRunning by viewModel.isRunning + val isShortcutLaunch by viewModel.isShortcutLaunch + + DisposableEffect(Unit) { + viewModel.startEmulation() + audioTrack.play() + + onDispose { + audioTrack.stop() + audioTrack.release() + } + } + + LaunchedEffect(isRunning) { + viewModel.runMainLoop(emulatorView, audioTrack) + } + + EmulatorBackHandler( + controller = controller, + pauseEmulation = viewModel::pauseEmulation, + resumeEmulation = viewModel::startEmulation, + isShortcutLaunch = isShortcutLaunch, + ) + + FullScreenLandscapeBox { + AndroidView( + factory = { emulatorView }, + modifier = Modifier + .align(Alignment.Center) + .fillMaxSize() + ) + GamePadLayout(viewModel = viewModel) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorView.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorView.kt new file mode 100644 index 0000000..e6aece3 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorView.kt @@ -0,0 +1,39 @@ +package dev.luckasranarison.mes.ui.emulator + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.view.View +import androidx.core.graphics.scale +import dev.luckasranarison.mes.lib.SCREEN_HEIGHT +import dev.luckasranarison.mes.lib.SCREEN_WIDTH + +class EmulatorView(context: Context) : View(context) { + private val screen: Bitmap = + Bitmap.createBitmap(SCREEN_WIDTH, SCREEN_HEIGHT, Bitmap.Config.ARGB_8888) + + init { + val pixels = Array(SCREEN_WIDTH * SCREEN_HEIGHT) { 0xFFFFFFFF.toInt() } + screen.setPixels(pixels.toIntArray(), 0, SCREEN_WIDTH, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val viewWidth = width.toFloat() + val viewHeight = height.toFloat() + + val aspectRatio = SCREEN_HEIGHT.toFloat() / SCREEN_WIDTH.toFloat() + val scaledWidth = viewHeight / aspectRatio + val scaledBitmap = screen.scale(scaledWidth.toInt(), viewHeight.toInt(), false) + val left = (viewWidth - scaledWidth) / 2 + + canvas.drawBitmap(scaledBitmap, left, 0f, Paint()) + } + + fun updateScreenData(buffer: IntArray) { + screen.setPixels(buffer, 0, SCREEN_WIDTH, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT) + invalidate() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/FullScreenContainer.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/FullScreenContainer.kt new file mode 100644 index 0000000..a0898c6 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/FullScreenContainer.kt @@ -0,0 +1,45 @@ +package dev.luckasranarison.mes.ui.emulator + +import android.app.Activity +import android.content.pm.ActivityInfo +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +@Composable +fun FullScreenLandscapeBox(content: @Composable (BoxScope.() -> Unit)) { + val view = LocalView.current + val ctx = LocalContext.current as Activity + + DisposableEffect(Unit) { + val insetsController = WindowCompat.getInsetsController(ctx.window, view) + val systemBars = WindowInsetsCompat.Type.systemBars() + + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + insetsController.hide(systemBars) + ctx.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + + onDispose { + insetsController.show(systemBars) + ctx.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { content() } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/ActionButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/ActionButton.kt new file mode 100644 index 0000000..b7abac2 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/ActionButton.kt @@ -0,0 +1,40 @@ +package dev.luckasranarison.mes.ui.gamepad + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.luckasranarison.mes.lib.Button + +@Composable +fun ActionButton(text: String, onPress: (Boolean) -> Unit) { + BaseButton( + modifier = Modifier + .clip(CircleShape) + .size(48.dp), + onPress = { state -> onPress(state) }, + ) { + Text( + text = text, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } +} + +@Composable +fun ActionPad(modifier: Modifier, onPress: (Button, Boolean) -> Unit) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(24.dp)) { + ActionButton(text = "B") { state -> onPress(Button.B, state) } + ActionButton(text = "A") { state -> onPress(Button.A, state) } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/BaseButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/BaseButton.kt new file mode 100644 index 0000000..f397cea --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/BaseButton.kt @@ -0,0 +1,46 @@ +package dev.luckasranarison.mes.ui.gamepad + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput + +@Composable +fun BaseButton( + modifier: Modifier, + onPress: (Boolean) -> Unit, + content: @Composable (BoxScope.() -> Unit) = {} +) { + var isPressed by remember { mutableStateOf(false) } + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .background( + Color.Gray.copy( + alpha = if (isPressed) 0.5f else 0.8f + ) + ) + .pointerInput(Unit) { + detectTapGestures(onPress = { + try { + onPress(true) + isPressed = true + awaitRelease() + } finally { + onPress(false) + isPressed = false + } + }) + } + ) { content() } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/DirectionButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/DirectionButton.kt new file mode 100644 index 0000000..747c1ca --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/DirectionButton.kt @@ -0,0 +1,79 @@ +package dev.luckasranarison.mes.ui.gamepad + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +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.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.lib.Button + +@Composable +fun DirectionButton( + modifier: Modifier, + icon: ImageVector, + desc: String, + onPress: (Boolean) -> Unit +) { + BaseButton( + modifier = modifier.size(52.dp), + onPress = { state -> onPress(state) }, + ) { + Icon( + imageVector = icon, + contentDescription = desc, + tint = Color.White, + modifier = Modifier.graphicsLayer(scaleX = 1.2f, scaleY = 1.2f) + ) + } +} + +@Composable +fun DirectionPad(modifier: Modifier, onPress: (Button, Boolean) -> Unit) { + Box(modifier = modifier.size((52 * 3).dp)) { + DirectionButton( + icon = Icons.Default.KeyboardArrowUp, + desc = "Up", + modifier = Modifier + .align(Alignment.TopCenter) + .clip(RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp)), + onPress = { state -> onPress(Button.Up, state) }, + ) + DirectionButton( + icon = Icons.Default.KeyboardArrowDown, + desc = "Down", + modifier = Modifier + .align(Alignment.BottomCenter) + .clip(RoundedCornerShape(bottomStart = 5.dp, bottomEnd = 5.dp)), + onPress = { state -> onPress(Button.Down, state) }, + ) + DirectionButton( + icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + desc = "Left", + modifier = Modifier + .align(Alignment.CenterStart) + .clip(RoundedCornerShape(topStart = 5.dp, bottomStart = 5.dp)), + onPress = { state -> onPress(Button.Left, state) }, + ) + DirectionButton( + icon = Icons.AutoMirrored.Filled.KeyboardArrowRight, + desc = "Right", + modifier = Modifier + .align(Alignment.CenterEnd) + .clip(RoundedCornerShape(topEnd = 5.dp, bottomEnd = 5.dp)), + onPress = { state -> onPress(Button.Right, state) }, + ) + } +} diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/GamePadLayout.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/GamePadLayout.kt new file mode 100644 index 0000000..8c0ceea --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/GamePadLayout.kt @@ -0,0 +1,69 @@ +package dev.luckasranarison.mes.ui.gamepad + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.ui.settings.FloatingSettings +import dev.luckasranarison.mes.vm.EmulatorViewModel + +@Composable +fun GamePadLayout(viewModel: EmulatorViewModel) { + var showSettings by remember { mutableStateOf(false) } + + if (showSettings) { + FloatingSettings( + viewModel = viewModel, + onExit = { showSettings = false } + ) + } + + LaunchedEffect(showSettings) { + if (showSettings) { + viewModel.pauseEmulation() + } else { + viewModel.startEmulation() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + DirectionPad( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 72.dp, top = 72.dp), + onPress = viewModel::updateController + ) + MenuPad( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + onPress = viewModel::updateController + ) + ActionPad( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 48.dp, top = 72.dp), + onPress = viewModel::updateController + ) + IconButton( + onClick = { showSettings = true }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(24.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + tint = Color.Gray, + modifier = Modifier.size(32.dp) + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/MenuButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/MenuButton.kt new file mode 100644 index 0000000..80952c3 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/MenuButton.kt @@ -0,0 +1,37 @@ +package dev.luckasranarison.mes.ui.gamepad + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.luckasranarison.mes.lib.Button + +@Composable +fun MenuButton(text: String, onPress: (Boolean) -> Unit) { + BaseButton( + modifier = Modifier.clip(RoundedCornerShape(5.dp)), + onPress = { state -> onPress(state) }, + ) { + Text( + text = text, + fontSize = 10.sp, + modifier = Modifier.padding(vertical = 2.dp, horizontal = 10.dp), + fontWeight = FontWeight.Bold, + color = Color.White + ) + } +} + +@Composable +fun MenuPad(modifier: Modifier, onPress: (Button, Boolean) -> Unit) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + MenuButton(text = "SELECT") { state -> onPress(Button.Select, state) } + MenuButton(text = "START") { state -> onPress(Button.Start, state) } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/home/DirectoryChooser.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/DirectoryChooser.kt new file mode 100644 index 0000000..48faedb --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/DirectoryChooser.kt @@ -0,0 +1,28 @@ +package dev.luckasranarison.mes.ui.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun DirectoryChooser(modifier: Modifier, onChoose: () -> Unit) { + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier.align(Alignment.Center), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "ROM directory is not set. Please choose one") + Button(onClick = onChoose) { + Text(text = "Add directory") + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/home/FloatingButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/FloatingButton.kt new file mode 100644 index 0000000..77916aa --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/FloatingButton.kt @@ -0,0 +1,38 @@ +package dev.luckasranarison.mes.ui.home + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.R + +@Composable +fun FloatingButton(onClick: () -> Unit) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.BottomEnd + ) { + FloatingActionButton ( + onClick = onClick, + containerColor = MaterialTheme.colorScheme.primary, + elevation = FloatingActionButtonDefaults.elevation(2.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.nes_icon), + contentDescription = "Upload", + modifier = Modifier.size(24.dp), + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/home/HomeScreen.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/HomeScreen.kt new file mode 100644 index 0000000..28e15e6 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/HomeScreen.kt @@ -0,0 +1,93 @@ +package dev.luckasranarison.mes.ui.home + +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavHostController +import dev.luckasranarison.mes.Activities +import dev.luckasranarison.mes.Routes +import dev.luckasranarison.mes.vm.EmulatorViewModel +import dev.luckasranarison.mes.vm.RomLoadingState +import dev.luckasranarison.mes.ui.rom.RomList + +@Composable +fun Home(viewModel: EmulatorViewModel, controller: NavHostController) { + val ctx = LocalContext.current + val romFiles by viewModel.romFiles + val romLoadingState by viewModel.romLoadingState + val romDirectory by viewModel.romDirectory.observeAsState() + var showInfoDialog by remember { mutableStateOf(false) } + + val loadRomFromFile = rememberLauncherForActivityResult(Activities.GET_CONTENT) { uri -> + if (uri != null) viewModel.loadRomFromFile(ctx, uri) + } + + val chooseRomDirectory = rememberLauncherForActivityResult(Activities.GET_DIRECTORY) { uri -> + if (uri != null) viewModel.setRomDirectory(ctx, uri) + } + + LaunchedEffect(romDirectory) { + if (romDirectory != null) { + viewModel.loadRomFromDirectory(ctx, Uri.parse(romDirectory)) + } + } + + LaunchedEffect(romLoadingState) { + if (romLoadingState is RomLoadingState.Success) { + Log.i("mes", "launching emulator...") + controller.navigate(Routes.EMULATOR) + viewModel.setLoadStatus(RomLoadingState.None) + Log.i("mes", "loading state: $romLoadingState") + } + + if (romLoadingState is RomLoadingState.Error) { + val errorMessage = (romLoadingState as RomLoadingState.Error).message + Toast.makeText(ctx, errorMessage, Toast.LENGTH_SHORT).show() + viewModel.setLoadStatus(RomLoadingState.None) + } + } + + Scaffold( + topBar = { + HomeTopAppBar(controller = controller) + }, + floatingActionButton = { + FloatingButton(onClick = { loadRomFromFile.launch("application/octet-stream") }) + } + ) { innerPadding -> + when { + romDirectory != null && romFiles == null -> { // Loading + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.primary + ) + } + } + + romFiles == null -> DirectoryChooser( + modifier = Modifier.padding(innerPadding), + onChoose = { chooseRomDirectory.launch(null) } + ) + + else -> RomList( + modifier = Modifier.padding(innerPadding), + onRefresh = { viewModel.loadRomFromDirectory(ctx, Uri.parse(romDirectory)) }, + onSelect = { uri -> viewModel.loadRomFromFile(ctx, uri) }, + romFiles = romFiles!! + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/home/TopAppBar.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/TopAppBar.kt new file mode 100644 index 0000000..865db02 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/TopAppBar.kt @@ -0,0 +1,38 @@ +package dev.luckasranarison.mes.ui.home + +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import dev.luckasranarison.mes.Routes + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun HomeTopAppBar(controller: NavHostController) { + TopAppBar( + title = { Text("Mes Emulator") }, + actions = { + Row { + IconButton(onClick = { controller.navigate(Routes.INFO) }) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "Info" + ) + } + IconButton(onClick = { controller.navigate(Routes.SETTINGS) }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings" + ) + } + } + } + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/info/AppIcon.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/AppIcon.kt new file mode 100644 index 0000000..c488359 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/AppIcon.kt @@ -0,0 +1,27 @@ +package dev.luckasranarison.mes.ui.info + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.R + +@Composable +fun AppIcon() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = R.drawable.nes_icon), + contentDescription = "Icon", + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier + .padding(48.dp) + .size(82.dp), + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoScreen.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoScreen.kt new file mode 100644 index 0000000..c43a8a6 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoScreen.kt @@ -0,0 +1,64 @@ +package dev.luckasranarison.mes.ui.info + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import dev.luckasranarison.mes.Routes +import dev.luckasranarison.mes.ui.shared.GenericTopAppBar + +const val AUTHOR_EMAIL = "luckasranarison@gmail.com" +const val REPOSITORY_URL = "https://github.com/luckasranarison/mes" + +@Composable +fun Info(controller: NavHostController) { + val ctx = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val uriHandler = LocalUriHandler.current + val version = remember { getAppVersion(ctx) } + + Scaffold( + topBar = { + GenericTopAppBar( + title = "About", + onExit = { controller.popBackStack() } + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + ) { + AppIcon() + + HorizontalDivider(thickness = 1.dp) + + Section( + title = "Version", + description = version, + onClick = { clipboardManager.setText(AnnotatedString("Mes v${version}")) } + ) + Section( + title = "Author", + description = AUTHOR_EMAIL, + onClick = { uriHandler.openUri(makeMailMessage(AUTHOR_EMAIL)) } + ) + Section( + title = "Source", + description = REPOSITORY_URL, + onClick = { uriHandler.openUri(REPOSITORY_URL) } + ) + Section( + title = "Open source license", + onClick = { controller.navigate(Routes.LICENSE) } + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoSection.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoSection.kt new file mode 100644 index 0000000..2bf787f --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoSection.kt @@ -0,0 +1,30 @@ +package dev.luckasranarison.mes.ui.info + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun Section(title: String, description: String? = null, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(horizontal = 16.dp, vertical = 14.dp) + ) { + Text(text = title) + + if (description != null) { + Text( + text = description, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/info/Utils.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/Utils.kt new file mode 100644 index 0000000..d78eb8f --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/Utils.kt @@ -0,0 +1,10 @@ +package dev.luckasranarison.mes.ui.info + +import android.content.Context + +fun getAppVersion(ctx: Context) = + ctx.packageManager?.getPackageInfo(ctx.packageName, 0)?.versionName + ?: throw Exception("Failed to get package info") + +fun makeMailMessage(address: String) = + "https://mail.google.com/mail/?view=cm&fs=1&to=$address&su=Subject&body=Message" \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/license/BottomSheet.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/BottomSheet.kt new file mode 100644 index 0000000..84a9545 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/BottomSheet.kt @@ -0,0 +1,76 @@ +package dev.luckasranarison.mes.ui.license + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.mikepenz.aboutlibraries.entity.Library +import dev.luckasranarison.mes.R +import dev.luckasranarison.mes.ui.theme.Typography + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun BottomSheet(library: Library, onClose: () -> Unit) { + val uriHandler = LocalUriHandler.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onClose, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxHeight(0.6f) + .verticalScroll(rememberScrollState()) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = library.name, fontWeight = FontWeight.SemiBold) + Text( + text = "v${library.artifactVersion}", + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } + + if (library.website != null) { + IconButton(onClick = { uriHandler.openUri(library.website!!) }) { + Icon( + painter = painterResource(id = R.drawable.web_search), + contentDescription = "Website" + ) + } + } + } + + Text( + text = library.description ?: "No description", + modifier = Modifier.padding(16.dp) + ) + + HorizontalDivider(thickness = 1.dp) + + Text( + text = library.licenses + .mapNotNull { it.licenseContent } + .joinToString("\n"), + modifier = Modifier.padding(16.dp), + style = Typography.bodyMedium + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/license/LibraryContainer.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/LibraryContainer.kt new file mode 100644 index 0000000..7b68c4a --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/LibraryContainer.kt @@ -0,0 +1,50 @@ +package dev.luckasranarison.mes.ui.license + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.mikepenz.aboutlibraries.entity.Library +import com.mikepenz.aboutlibraries.ui.compose.m3.util.author +import dev.luckasranarison.mes.ui.theme.Typography + +@Composable +fun LibraryContainer(lib: Library) { + var showSheet by remember { mutableStateOf(false) } + + if (showSheet) { + BottomSheet(library = lib, onClose = { showSheet = false }) + } + + Box(modifier = Modifier + .fillMaxWidth() + .clickable { showSheet = true } + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = lib.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = lib.author, + style = Typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } + Text(text = lib.artifactVersion ?: "Unknown") + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/license/License.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/License.kt new file mode 100644 index 0000000..32bb9b1 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/License.kt @@ -0,0 +1,40 @@ +package dev.luckasranarison.mes.ui.license + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavHostController +import com.mikepenz.aboutlibraries.ui.compose.m3.rememberLibraries +import dev.luckasranarison.mes.ui.shared.GenericTopAppBar +import dev.luckasranarison.mes.R + +@Composable +fun License(controller: NavHostController) { + val ctx = LocalContext.current + + val libs by rememberLibraries { + ctx.resources + .openRawResource(R.raw.aboutlibraries) + .readBytes() + .decodeToString() + } + + Scaffold( + topBar = { + GenericTopAppBar( + title = "Open source license", + onExit = { controller.popBackStack() } + ) + } + ) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + items(libs?.libraries?.size ?: 0) { index -> + LibraryContainer(libs!!.libraries[index]) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/InitialBox.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/InitialBox.kt new file mode 100644 index 0000000..9ef48d1 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/InitialBox.kt @@ -0,0 +1,41 @@ +package dev.luckasranarison.mes.ui.rom + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.graphics.Color +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.ui.theme.Typography + +@Composable +fun InitialBox( + name: String, + modifier: Modifier, + foreground: Color, + background: Color +) { + Box( + modifier = modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .background(background), + contentAlignment = Alignment.Center + ) { + Text( + text = name + .split(" ") + .take(3) + .mapNotNull { it.firstOrNull() } + .joinToString("") + .ifEmpty { "NES" }, + style = Typography.titleSmall, + color = foreground, + ) + } +} diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomContainer.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomContainer.kt new file mode 100644 index 0000000..0d4b2d1 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomContainer.kt @@ -0,0 +1,72 @@ +package dev.luckasranarison.mes.ui.rom + +import android.net.Uri +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.* +import androidx.compose.runtime.* +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 dev.luckasranarison.mes.data.RomFile +import dev.luckasranarison.mes.ui.rom.sheet.BottomSheet +import dev.luckasranarison.mes.ui.theme.Typography + +@Composable +fun RomContainer(rom: RomFile, onSelect: (Uri) -> Unit) { + var isSheetVisible by remember { mutableStateOf(false) } + + if (isSheetVisible) { + BottomSheet( + rom = rom, + onClose = { isSheetVisible = false }, + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable { onSelect(rom.uri) } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + InitialBox( + name = rom.baseName(), + modifier = Modifier.padding(start = 8.dp), + foreground = MaterialTheme.colorScheme.onSecondary, + background = MaterialTheme.colorScheme.secondary + ) + + Text( + text = rom.baseName(), + style = Typography.titleMedium, + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + IconButton(onClick = { isSheetVisible = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Details", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomList.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomList.kt new file mode 100644 index 0000000..769ddd8 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomList.kt @@ -0,0 +1,60 @@ +package dev.luckasranarison.mes.ui.rom + +import android.net.Uri +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.pulltorefresh.* +import androidx.compose.runtime.* +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import dev.luckasranarison.mes.data.RomFile +import kotlinx.coroutines.launch + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun RomList( + modifier: Modifier, + romFiles: List, + onSelect: (Uri) -> Unit, + onRefresh: suspend () -> Unit +) { + val pullToRefreshState = rememberPullToRefreshState() + val coroutineScope = rememberCoroutineScope() + var isRefreshing by remember { mutableStateOf(false) } + + PullToRefreshBox( + modifier = modifier.fillMaxSize(), + state = pullToRefreshState, + isRefreshing = isRefreshing, + onRefresh = { + coroutineScope.launch { + isRefreshing = true + onRefresh() + isRefreshing = false + } + }, + indicator = { + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = isRefreshing, + color = Color.White, + containerColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(romFiles.size) { index -> + val rom = romFiles[index] + RomContainer( + rom = rom, + onSelect = { onSelect(rom.uri) }, + ) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/BottomSheet.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/BottomSheet.kt new file mode 100644 index 0000000..7143fd1 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/BottomSheet.kt @@ -0,0 +1,55 @@ +package dev.luckasranarison.mes.ui.rom.sheet + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.data.RomFile +import dev.luckasranarison.mes.ui.theme.Typography +import kotlinx.coroutines.launch + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun BottomSheet( + rom: RomFile, + onClose: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = { onClose() }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + TopRow(rom = rom) + + Spacer(modifier = Modifier.height(16.dp)) + + MetadataList(rom = rom) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + scope.launch { sheetState.hide(); onClose() } + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.primary) + ) { + Text( + "Close", + style = Typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/Metadata.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/Metadata.kt new file mode 100644 index 0000000..78a79a0 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/Metadata.kt @@ -0,0 +1,55 @@ +package dev.luckasranarison.mes.ui.rom.sheet + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.data.RomFile +import dev.luckasranarison.mes.lib.CHR_ROM_PAGE_SIZE +import dev.luckasranarison.mes.lib.PRG_RAM_SIZE +import dev.luckasranarison.mes.lib.PRG_ROM_PAGE_SIZE +import dev.luckasranarison.mes.ui.theme.Typography + +fun formatPage(count: Byte, size: Int) = + if (count > 0) "$count (${count * size / 1024} KB)" else "None" + +@Composable +fun Metadata(key: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = key, + style = Typography.bodyMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = value, + style = Typography.bodyMedium, + modifier = Modifier.weight(1f), + textAlign = TextAlign.End, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +fun MetadataList(rom: RomFile) { + val attributes = rom.getAttributes() + + Metadata("Attributes", if (attributes.isEmpty()) "None" else attributes.joinToString()) + Metadata("Size", "${rom.size / 1024} KB") + Metadata("Mapper", rom.header.mapper.toString()) + Metadata("Mirroring", rom.header.mirroring) + Metadata("Battery", if (rom.header.battery) "Yes" else "No") + Metadata("PRG ROM", formatPage(rom.header.prgRomPages, PRG_ROM_PAGE_SIZE)) + Metadata("PRG RAM", formatPage(rom.header.prgRamPages, PRG_RAM_SIZE)) + Metadata("CHR ROM", formatPage(rom.header.chrRomPages, CHR_ROM_PAGE_SIZE)) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/TopRow.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/TopRow.kt new file mode 100644 index 0000000..1014fa8 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/TopRow.kt @@ -0,0 +1,52 @@ +package dev.luckasranarison.mes.ui.rom.sheet + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.R +import dev.luckasranarison.mes.data.RomFile +import dev.luckasranarison.mes.extra.createShortcut +import dev.luckasranarison.mes.ui.rom.InitialBox +import dev.luckasranarison.mes.ui.theme.Typography + +@Composable +fun TopRow(rom: RomFile) { + val ctx = LocalContext.current + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + InitialBox( + name = rom.baseName(), + modifier = Modifier, + foreground = MaterialTheme.colorScheme.onPrimary, + background = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = rom.baseName(), + style = Typography.bodyLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + IconButton(onClick = { createShortcut(ctx, rom) }) { + Icon( + painter = painterResource(id = R.drawable.app_shortcut), + contentDescription = "Shortcut", + modifier = Modifier.size(20.dp) + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/FloatingSettings.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/FloatingSettings.kt new file mode 100644 index 0000000..9d7c1a5 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/FloatingSettings.kt @@ -0,0 +1,39 @@ +package dev.luckasranarison.mes.ui.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +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.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import dev.luckasranarison.mes.vm.EmulatorViewModel + +@Composable +fun FloatingSettings(viewModel: EmulatorViewModel, onExit: () -> Unit) { + Dialog( + onDismissRequest = onExit, + properties = DialogProperties( + usePlatformDefaultWidth = false, + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.6f) + .fillMaxHeight() + ) { + Card( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .align(Alignment.Center) + ) { + Settings(viewModel = viewModel, onExit = onExit) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsScreen.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..7f9cf14 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsScreen.kt @@ -0,0 +1,96 @@ +package dev.luckasranarison.mes.ui.settings + +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.Activities +import dev.luckasranarison.mes.ui.shared.GenericTopAppBar +import dev.luckasranarison.mes.vm.EmulatorViewModel + +@Composable +fun Settings(viewModel: EmulatorViewModel, onExit: () -> Unit) { + val ctx = LocalContext.current + val romDirectory by viewModel.romDirectory.observeAsState() + val enableApu by viewModel.enableApu.observeAsState() + val colorPalette by viewModel.colorPalette.observeAsState() + var showPaletteOptions by remember { mutableStateOf(false) } + + val wrapBlock: (() -> Unit) -> Unit = { block -> + try { + block() + } catch (err: Exception) { + val message = err.message ?: "An unknown error occurred" + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + } + } + + val chooseRomDirectory = rememberLauncherForActivityResult(Activities.GET_DIRECTORY) { uri -> + if (uri != null) wrapBlock { viewModel.setRomDirectory(ctx, uri) } + } + + val chooseColorPalette = rememberLauncherForActivityResult(Activities.GET_CONTENT) { uri -> + if (uri != null) wrapBlock { viewModel.setColorPalette(ctx, uri) } + + } + + LaunchedEffect(colorPalette) { + showPaletteOptions = colorPalette != null + } + + Scaffold( + topBar = { + GenericTopAppBar(title = "Settings", onExit = onExit) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Section(header = "ROMs") { + TextValue( + label = "Directory", + value = romDirectory?.toPathName(ctx) ?: "Unset", + onChange = { chooseRomDirectory.launch(null) } + ) + } + Section(header = "Emulator") { + BooleanValue( + label = "Custom palette", + description = "Use custom .pal palette", + value = showPaletteOptions, + onToggle = { value -> + showPaletteOptions = value + if (!value) viewModel.unsetColorPalette() + } + ) + + if (showPaletteOptions) { + TextValue( + label = "Palette", + value = "Custom palette file", + onChange = { chooseColorPalette.launch("*/*") } + ) + } + + BooleanValue( + label = "Sound", + description = "Enable APU emulation", + value = enableApu ?: true, + onToggle = { viewModel.toggleApuState() } + ) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsSection.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsSection.kt new file mode 100644 index 0000000..3e914ca --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsSection.kt @@ -0,0 +1,81 @@ +package dev.luckasranarison.mes.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.luckasranarison.mes.ui.theme.Typography + +@Composable +fun TextValue(label: String, value: String, onChange: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onChange() } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = label, + modifier = Modifier, + ) + Text( + text = value, + modifier = Modifier, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } +} + +@Composable +fun BooleanValue(label: String, description: String, value: Boolean, onToggle: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle(value) } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + modifier = Modifier, + ) + Text( + text = description, + modifier = Modifier, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f) + ) + } + Switch( + checked = value, + onCheckedChange = onToggle, + colors = SwitchDefaults.colors( + uncheckedBorderColor = MaterialTheme.colorScheme.onBackground, + uncheckedTrackColor = MaterialTheme.colorScheme.onBackground, + uncheckedThumbColor = MaterialTheme.colorScheme.background, + ) + ) + } +} + +@Composable +fun Section(header: String, options: @Composable ColumnScope.() -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = header, + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.primary, + style = Typography.titleMedium + ) + Column { options() } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/Utils.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/Utils.kt new file mode 100644 index 0000000..51ef155 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/Utils.kt @@ -0,0 +1,11 @@ +package dev.luckasranarison.mes.ui.settings + +import android.content.Context +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile + +fun String.toPathName(context: Context): String { + val uri = this.toUri() + val documentFile = DocumentFile.fromTreeUri(context, uri) + return documentFile?.name ?: "Unknown Directory" +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/shared/TopAppBar.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/shared/TopAppBar.kt new file mode 100644 index 0000000..92b3970 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/shared/TopAppBar.kt @@ -0,0 +1,29 @@ +package dev.luckasranarison.mes.ui.shared + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun GenericTopAppBar( + title: String, + onExit: () -> Unit, +) { + TopAppBar( + title = { Text(text = title) }, + navigationIcon = { + IconButton(onClick = onExit) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Color.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Color.kt new file mode 100644 index 0000000..2851610 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package dev.luckasranarison.mes.ui.theme + +import androidx.compose.ui.graphics.Color + +data object ColorScheme { + val Primary = Color(0xFFE60012) + val Secondary = Color(0xFF484848) + val Light = Color(0xFFFFFFFF) + val Dark = Color(0xFF1E1E1E) + val Smoke = Color(0x00000020) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Theme.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Theme.kt new file mode 100644 index 0000000..99af6b5 --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Theme.kt @@ -0,0 +1,48 @@ +package dev.luckasranarison.mes.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +private val DarkColorScheme = darkColorScheme( + primary = ColorScheme.Primary, + secondary = ColorScheme.Secondary, + tertiary = ColorScheme.Smoke, + background = ColorScheme.Dark, + onBackground = ColorScheme.Light, + onPrimary = ColorScheme.Light, + onSecondary = ColorScheme.Light, + surface = ColorScheme.Dark, + surfaceTint = ColorScheme.Dark, +) + +private val LightColorScheme = lightColorScheme( + primary = ColorScheme.Primary, + secondary = ColorScheme.Secondary, + tertiary = ColorScheme.Smoke, + background = ColorScheme.Light, + onBackground = ColorScheme.Dark, + onPrimary = ColorScheme.Light, + onSecondary = ColorScheme.Light, + surface = ColorScheme.Light, + surfaceTint = ColorScheme.Light, +) + +@Composable +fun MesTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = when { + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Type.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Type.kt new file mode 100644 index 0000000..dc8a7fa --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Type.kt @@ -0,0 +1,17 @@ +package dev.luckasranarison.mes.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( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) +) \ No newline at end of file diff --git a/android/app/src/main/java/dev/luckasranarison/mes/vm/ViewModel.kt b/android/app/src/main/java/dev/luckasranarison/mes/vm/ViewModel.kt new file mode 100644 index 0000000..9f5a8ab --- /dev/null +++ b/android/app/src/main/java/dev/luckasranarison/mes/vm/ViewModel.kt @@ -0,0 +1,202 @@ +package dev.luckasranarison.mes.vm + +import android.content.Context +import android.content.Intent +import android.media.AudioTrack +import android.net.Uri +import android.provider.DocumentsContract +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.* +import androidx.lifecycle.viewmodel.CreationExtras +import dev.luckasranarison.mes.data.RomFile +import dev.luckasranarison.mes.data.SettingsRepository +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import dev.luckasranarison.mes.data.dataStore +import dev.luckasranarison.mes.lib.* +import dev.luckasranarison.mes.ui.emulator.EmulatorView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.mapNotNull +import java.io.IOException + +class EmulatorViewModel(private val settings: SettingsRepository) : ViewModel() { + private val _romLoadingState = mutableStateOf(RomLoadingState.None) + private val _isRunning = mutableStateOf(false) + private val _romFiles = mutableStateOf?>(null) + private val _isShortcutLaunch = mutableStateOf(false) + private val nes: NesObject = NesObject() + private val controller = mutableStateOf(Controller()) + + val romDirectory = settings.getRomDirectory().asLiveData() + val enableApu = settings.getApuState().asLiveData() + val colorPalette = settings.getColorPalette().asLiveData() + val romLoadingState: State = _romLoadingState + val romFiles: State?> = _romFiles + val isRunning: State = _isRunning + val isShortcutLaunch: State = _isShortcutLaunch + + init { + viewModelScope.launch { + settings.getColorPalette() + .mapNotNull { it } + .collect{ nes.setColorPalette(it) } + } + } + + override fun onCleared() { + super.onCleared() + nes.free() + } + + fun loadRomFromFile(ctx: Context, uri: Uri) { + try { + val stream = ctx.contentResolver.openInputStream(uri) + + stream.use { handle -> + val rom = handle?.readBytes() ?: throw IOException("Failed to read ROM") + nes.setCartridge(rom) + nes.reset() + _romLoadingState.value = RomLoadingState.Success + } + } catch (err: Exception) { + val message = err.message ?: "An unknown error occurred" + _romLoadingState.value = RomLoadingState.Error(message) + } + } + + suspend fun loadRomFromDirectory(ctx: Context, uri: Uri) { + withContext(Dispatchers.IO) { + val parentId = DocumentsContract.getTreeDocumentId(uri) + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, parentId) + val tree = DocumentFile.fromTreeUri(ctx, childrenUri) + val files = tree?.listFiles() + ?.mapNotNull { file -> runCatching { readRomMetadata(ctx, file) }.getOrNull() } + + _romFiles.value = files + } + } + + private fun readRomMetadata(ctx: Context, file: DocumentFile): RomFile { + val stream = ctx.contentResolver.openInputStream(file.uri) + + stream?.use { handle -> + val headerBuffer = ByteArray(4) + val bytesRead = handle.read(headerBuffer, 0, 4) + + if (bytesRead == 4 && headerBuffer contentEquals INES_ASCII) { + val remaining = handle.readBytes() + val stringMetaData = Nes.serializeRomHeader(headerBuffer + remaining) + return RomFile(file, stringMetaData) + } + } + + throw Exception("Not a valid iNES file") + } + + fun setShortcutLaunch() { + _isShortcutLaunch.value = true + } + + fun setRomDirectory(ctx: Context, uri: Uri) { + ctx.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + viewModelScope.launch { settings.setRomDirectory(uri.toString()) } + } + + fun setLoadStatus(state: RomLoadingState) { + _romLoadingState.value = state + } + + fun pauseEmulation() { + _isRunning.value = false + } + + fun startEmulation() { + _isRunning.value = true + } + + fun updateController(button: Button, state: Boolean) { + controller.value = controller.value.update(button, state) + } + + fun toggleApuState() { + viewModelScope.launch { + settings.toggleApuState() + } + } + + fun unsetColorPalette() { + viewModelScope.launch { + nes.setColorPalette(null) + settings.setColorPalette(null) + } + } + + fun setColorPalette(ctx: Context, uri: Uri) { + val stream = ctx.contentResolver.openInputStream(uri) + + stream?.use { handle -> + val palette = handle.readBytes() + nes.setColorPalette(palette) + viewModelScope.launch { settings.setColorPalette(palette) } + } + } + + suspend fun runMainLoop(view: EmulatorView, audio: AudioTrack) { + var lastTimestamp = System.nanoTime() + + while (isRunning.value) { + val timestamp = System.nanoTime() + val delta = timestamp - lastTimestamp + + if (delta >= FRAME_DURATION) { + lastTimestamp += FRAME_DURATION + stepFrame(view, audio) + } else { + delay((FRAME_DURATION - delta) / 1_000_000) + } + } + } + + private fun stepFrame(view: EmulatorView, audio: AudioTrack) { + nes.stepFrame() + + val frameBuffer = nes.updateFrameBuffer() + view.updateScreenData(frameBuffer) + + val (audioBuffer, length) = nes.updateAudioBuffer() + + if (enableApu.value != false) { + audio.write(audioBuffer, 0, length, AudioTrack.WRITE_NON_BLOCKING) + nes.clearAudioBuffer() + } + + nes.setControllerState(0, controller.value.state()) + nes.stepVBlank() + } + + companion object { + val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: Class, + extras: CreationExtras + ): T { + val application = extras[APPLICATION_KEY]!! + val store = application.dataStore + val repository = SettingsRepository(store) + return EmulatorViewModel(repository) as T + } + } + } +} + +sealed class RomLoadingState { + data object None : RomLoadingState() + data object Success : RomLoadingState() + data class Error(val message: String) : RomLoadingState() +} \ No newline at end of file diff --git a/android/app/src/main/jniLibs/arm64-v8a/.keep b/android/app/src/main/jniLibs/arm64-v8a/.keep new file mode 100644 index 0000000..e69de29 diff --git a/android/app/src/main/jniLibs/x86_64/.keep b/android/app/src/main/jniLibs/x86_64/.keep new file mode 100644 index 0000000..e69de29 diff --git a/android/app/src/main/res/drawable/app_shortcut.xml b/android/app/src/main/res/drawable/app_shortcut.xml new file mode 100644 index 0000000..fac9a3a --- /dev/null +++ b/android/app/src/main/res/drawable/app_shortcut.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..c4e85a3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/nes_icon.xml b/android/app/src/main/res/drawable/nes_icon.xml new file mode 100644 index 0000000..946e1b4 --- /dev/null +++ b/android/app/src/main/res/drawable/nes_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/web_search.xml b/android/app/src/main/res/drawable/web_search.xml new file mode 100644 index 0000000..0e7c701 --- /dev/null +++ b/android/app/src/main/res/drawable/web_search.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..220b80a Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..a40c50f Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..10b007b Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..cda8360 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..767afdc Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..6070d4a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..f98486c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..6af4f3e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..013ba73 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..a59331b Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/android/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/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..a86a993 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #E60012 + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b643fb9 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Mes + \ No newline at end of file diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..ae8e7bd --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +