diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..958abe7
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,6 @@
+[submodule "submodules/RetroArch"]
+ path = submodules/RetroArch
+ url = https://github.com/libretro/RetroArch.git
+[submodule "submodules/ppsspp"]
+ path = submodules/ppsspp
+ url = https://github.com/hrydgard/ppsspp.git
diff --git a/app-default/build.gradle.kts b/app-default/build.gradle.kts
index 2ad5fc6..81519e9 100644
--- a/app-default/build.gradle.kts
+++ b/app-default/build.gradle.kts
@@ -1,4 +1,5 @@
import org.jetbrains.kotlin.konan.properties.Properties
+import java.io.ByteArrayOutputStream
import java.io.FileInputStream
plugins {
@@ -18,9 +19,12 @@ android {
applicationId = "moe.tabidachi.emulator"
minSdk = 23
targetSdk = 28
- versionCode = 1
- versionName = "1.0"
+ versionCode = 2
+ versionName = "1.1-${getGitHash()}"
+ ndk {
+ abiFilters.add("armeabi-v7a")
+ }
}
signingConfigs {
val properties = Properties().apply {
@@ -50,7 +54,7 @@ android {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
)
- //signingConfig = signingConfigs.getByName("release")
+ signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
@@ -63,6 +67,16 @@ android {
buildFeatures {
compose = true
}
+ applicationVariants.all {
+ outputs.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
+ .forEach { output ->
+ val outputFileName = "Emulator-${versionName}-${baseName}.apk"
+ output.outputFileName = outputFileName
+ }
+ }
+ lint {
+ baseline = file("lint-baseline.xml")
+ }
}
dependencies {
@@ -105,4 +119,18 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
+ testImplementation(kotlin("test"))
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+}
+
+fun getGitHash(): String {
+ return ByteArrayOutputStream().use {
+ project.exec {
+ commandLine("git", "rev-parse", "--short", "HEAD")
+ standardOutput = it
+ }
+ it.toByteArray().let(::String).trim()
+ }
}
\ No newline at end of file
diff --git a/app-default/lint-baseline.xml b/app-default/lint-baseline.xml
new file mode 100644
index 0000000..4284a3b
--- /dev/null
+++ b/app-default/lint-baseline.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app-default/proguard-rules.pro b/app-default/proguard-rules.pro
index 481bb43..33eb1fa 100644
--- a/app-default/proguard-rules.pro
+++ b/app-default/proguard-rules.pro
@@ -18,4 +18,8 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
+# ppsspp
+-keep class org.ppsspp.ppsspp.** { *; }
+# RetroArch
+-keep class com.retroarch.** { *; }
\ No newline at end of file
diff --git a/app-default/src/main/AndroidManifest.xml b/app-default/src/main/AndroidManifest.xml
index 1d2840d..f497ec7 100644
--- a/app-default/src/main/AndroidManifest.xml
+++ b/app-default/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
+ android:theme="@style/Theme.Emulator"
+ tools:replace="android:banner,android:name">
@@ -28,8 +31,19 @@
+
+
+
+
\ No newline at end of file
diff --git a/app-default/src/main/ic_launcher-playstore.png b/app-default/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..ba8dbe2
Binary files /dev/null and b/app-default/src/main/ic_launcher-playstore.png differ
diff --git a/app-default/src/main/java/androidx/compose/material3/ProgressIndicator.kt b/app-default/src/main/java/androidx/compose/material3/ProgressIndicator.kt
new file mode 100644
index 0000000..bc8c9dc
--- /dev/null
+++ b/app-default/src/main/java/androidx/compose/material3/ProgressIndicator.kt
@@ -0,0 +1,295 @@
+package androidx.compose.material3
+
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.keyframes
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.progressSemantics
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.offset
+import androidx.tv.material3.MaterialTheme
+import kotlin.math.abs
+
+/**
+ * Indeterminate Material Design linear progress indicator.
+ *
+ * Progress indicators express an unspecified wait time or display the duration of a process.
+ *
+ * 
+ *
+ * @sample androidx.compose.material3.samples.IndeterminateLinearProgressIndicatorSample
+ *
+ * @param modifier the [Modifier] to be applied to this progress indicator
+ * @param color color of this progress indicator
+ * @param trackColor color of the track behind the indicator, visible when the progress has not
+ * reached the area of the overall indicator yet
+ * @param strokeCap stroke cap to use for the ends of this progress indicator
+ * @param gapSize size of the gap between the progress indicator and the track
+ */
+@Composable
+fun LinearProgressIndicator(
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colorScheme.primary,
+ trackColor: Color = MaterialTheme.colorScheme.secondaryContainer,
+ strokeCap: StrokeCap = StrokeCap.Round,
+ gapSize: Dp = 4.dp,
+) {
+ val infiniteTransition = rememberInfiniteTransition()
+ // Fractional position of the 'head' and 'tail' of the two lines drawn, i.e. if the head is 0.8
+ // and the tail is 0.2, there is a line drawn from between 20% along to 80% along the total
+ // width.
+ val firstLineHead =
+ infiniteTransition.animateFloat(
+ 0f,
+ 1f,
+ infiniteRepeatable(
+ animation =
+ keyframes {
+ durationMillis = LinearAnimationDuration
+ 0f at FirstLineHeadDelay using FirstLineHeadEasing
+ 1f at FirstLineHeadDuration + FirstLineHeadDelay
+ }
+ )
+ )
+ val firstLineTail =
+ infiniteTransition.animateFloat(
+ 0f,
+ 1f,
+ infiniteRepeatable(
+ animation =
+ keyframes {
+ durationMillis = LinearAnimationDuration
+ 0f at FirstLineTailDelay using FirstLineTailEasing
+ 1f at FirstLineTailDuration + FirstLineTailDelay
+ }
+ )
+ )
+ val secondLineHead =
+ infiniteTransition.animateFloat(
+ 0f,
+ 1f,
+ infiniteRepeatable(
+ animation =
+ keyframes {
+ durationMillis = LinearAnimationDuration
+ 0f at SecondLineHeadDelay using SecondLineHeadEasing
+ 1f at SecondLineHeadDuration + SecondLineHeadDelay
+ }
+ )
+ )
+ val secondLineTail =
+ infiniteTransition.animateFloat(
+ 0f,
+ 1f,
+ infiniteRepeatable(
+ animation =
+ keyframes {
+ durationMillis = LinearAnimationDuration
+ 0f at SecondLineTailDelay using SecondLineTailEasing
+ 1f at SecondLineTailDuration + SecondLineTailDelay
+ }
+ )
+ )
+ Canvas(
+ modifier
+ .then(IncreaseSemanticsBounds)
+ .progressSemantics()
+ .size(LinearIndicatorWidth, LinearIndicatorHeight)
+ ) {
+ val strokeWidth = size.height
+ val adjustedGapSize =
+ if (strokeCap == StrokeCap.Butt || size.height > size.width) {
+ gapSize
+ } else {
+ gapSize + strokeWidth.toDp()
+ }
+ val gapSizeFraction = adjustedGapSize / size.width.toDp()
+
+ // Track before line 1
+ if (firstLineHead.value < 1f - gapSizeFraction) {
+ val start = if (firstLineHead.value > 0) firstLineHead.value + gapSizeFraction else 0f
+ drawLinearIndicator(start, 1f, trackColor, strokeWidth, strokeCap)
+ }
+
+ // Line 1
+ if (firstLineHead.value - firstLineTail.value > 0) {
+ drawLinearIndicator(
+ firstLineHead.value,
+ firstLineTail.value,
+ color,
+ strokeWidth,
+ strokeCap,
+ )
+ }
+
+ // Track between line 1 and line 2
+ if (firstLineTail.value > gapSizeFraction) {
+ val start = if (secondLineHead.value > 0) secondLineHead.value + gapSizeFraction else 0f
+ val end = if (firstLineTail.value < 1f) firstLineTail.value - gapSizeFraction else 1f
+ drawLinearIndicator(start, end, trackColor, strokeWidth, strokeCap)
+ }
+
+ // Line 2
+ if (secondLineHead.value - secondLineTail.value > 0) {
+ drawLinearIndicator(
+ secondLineHead.value,
+ secondLineTail.value,
+ color,
+ strokeWidth,
+ strokeCap,
+ )
+ }
+
+ // Track after line 2
+ if (secondLineTail.value > gapSizeFraction) {
+ val end = if (secondLineTail.value < 1) secondLineTail.value - gapSizeFraction else 1f
+ drawLinearIndicator(0f, end, trackColor, strokeWidth, strokeCap)
+ }
+ }
+}
+
+private fun DrawScope.drawLinearIndicator(
+ startFraction: Float,
+ endFraction: Float,
+ color: Color,
+ strokeWidth: Float,
+ strokeCap: StrokeCap,
+) {
+ val width = size.width
+ val height = size.height
+ // Start drawing from the vertical center of the stroke
+ val yOffset = height / 2
+
+ val isLtr = layoutDirection == LayoutDirection.Ltr
+ val barStart = (if (isLtr) startFraction else 1f - endFraction) * width
+ val barEnd = (if (isLtr) endFraction else 1f - startFraction) * width
+
+ // if there isn't enough space to draw the stroke caps, fall back to StrokeCap.Butt
+ if (strokeCap == StrokeCap.Butt || height > width) {
+ // Progress line
+ drawLine(color, Offset(barStart, yOffset), Offset(barEnd, yOffset), strokeWidth)
+ } else {
+ // need to adjust barStart and barEnd for the stroke caps
+ val strokeCapOffset = strokeWidth / 2
+ val coerceRange = strokeCapOffset..(width - strokeCapOffset)
+ val adjustedBarStart = barStart.coerceIn(coerceRange)
+ val adjustedBarEnd = barEnd.coerceIn(coerceRange)
+
+ if (abs(endFraction - startFraction) > 0) {
+ // Progress line
+ drawLine(
+ color,
+ Offset(adjustedBarStart, yOffset),
+ Offset(adjustedBarEnd, yOffset),
+ strokeWidth,
+ strokeCap,
+ )
+ }
+ }
+}
+
+private val SemanticsBoundsPadding: Dp = 10.dp
+private val IncreaseSemanticsBounds: Modifier =
+ Modifier
+ .layout { measurable, constraints ->
+ val paddingPx = SemanticsBoundsPadding.roundToPx()
+ // We need to add vertical padding to the semantics bounds in order to meet
+ // screenreader green box minimum size, but we also want to
+ // preserve a visual appearance and layout size below that minimum
+ // in order to maintain backwards compatibility. This custom
+ // layout effectively implements "negative padding".
+ val newConstraint = constraints.offset(0, paddingPx * 2)
+ val placeable = measurable.measure(newConstraint)
+
+ // But when actually placing the placeable, create the layout without additional
+ // space. Place the placeable where it would've been without any extra padding.
+ val height = placeable.height - paddingPx * 2
+ val width = placeable.width
+ layout(width, height) { placeable.place(0, -paddingPx) }
+ }
+ .semantics(mergeDescendants = true) {}
+ .padding(vertical = SemanticsBoundsPadding)
+
+
+// LinearProgressIndicator Material specs
+
+// Width is given in the spec but not defined as a token.
+/*@VisibleForTesting*/
+internal val LinearIndicatorWidth = 240.dp
+
+/*@VisibleForTesting*/
+internal val LinearIndicatorHeight = 4.dp
+
+// CircularProgressIndicator Material specs
+// Diameter of the indicator circle
+/*@VisibleForTesting*/
+internal val CircularIndicatorDiameter = 48.dp - 4.dp * 2
+
+// Indeterminate linear indicator transition specs
+
+// Total duration for one cycle
+private const val LinearAnimationDuration = 1800
+
+// Duration of the head and tail animations for both lines
+private const val FirstLineHeadDuration = 750
+private const val FirstLineTailDuration = 850
+private const val SecondLineHeadDuration = 567
+private const val SecondLineTailDuration = 533
+
+// Delay before the start of the head and tail animations for both lines
+private const val FirstLineHeadDelay = 0
+private const val FirstLineTailDelay = 333
+private const val SecondLineHeadDelay = 1000
+private const val SecondLineTailDelay = 1267
+
+private val FirstLineHeadEasing = CubicBezierEasing(0.2f, 0f, 0.8f, 1f)
+private val FirstLineTailEasing = CubicBezierEasing(0.4f, 0f, 1f, 1f)
+private val SecondLineHeadEasing = CubicBezierEasing(0f, 0f, 0.65f, 1f)
+private val SecondLineTailEasing = CubicBezierEasing(0.1f, 0f, 0.45f, 1f)
+
+// Indeterminate circular indicator transition specs
+
+// The animation comprises of 5 rotations around the circle forming a 5 pointed star.
+// After the 5th rotation, we are back at the beginning of the circle.
+private const val RotationsPerCycle = 5
+
+// Each rotation is 1 and 1/3 seconds, but 1332ms divides more evenly
+private const val RotationDuration = 1332
+
+// When the rotation is at its beginning (0 or 360 degrees) we want it to be drawn at 12 o clock,
+// which means 270 degrees when drawing.
+private const val StartAngleOffset = -90f
+
+// How far the base point moves around the circle
+private const val BaseRotationAngle = 286f
+
+// How far the head and tail should jump forward during one rotation past the base point
+private const val JumpRotationAngle = 290f
+
+// Each rotation we want to offset the start position by this much, so we continue where
+// the previous rotation ended. This is the maximum angle covered during one rotation.
+private const val RotationAngleOffset = (BaseRotationAngle + JumpRotationAngle) % 360f
+
+// The head animates for the first half of a rotation, then is static for the second half
+// The tail is static for the first half and then animates for the second half
+private const val HeadAndTailAnimationDuration = (RotationDuration * 0.5).toInt()
+private const val HeadAndTailDelayDuration = HeadAndTailAnimationDuration
+
+// The easing for the head and tail jump
+private val CircularEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/MainActivity.kt b/app-default/src/main/java/moe/tabidachi/emulator/MainActivity.kt
index 6db197b..0e1f1c2 100644
--- a/app-default/src/main/java/moe/tabidachi/emulator/MainActivity.kt
+++ b/app-default/src/main/java/moe/tabidachi/emulator/MainActivity.kt
@@ -6,13 +6,19 @@ import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
+import androidx.lifecycle.ViewModelProvider
import androidx.tv.material3.Surface
import dagger.hilt.android.AndroidEntryPoint
import moe.tabidachi.emulator.ui.SharedNavHost
+import moe.tabidachi.emulator.ui.common.MediaChangeListener
import moe.tabidachi.emulator.ui.theme.EmulatorTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+ private val viewModel by lazy {
+ ViewModelProvider(this)[MainViewModel::class]
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
@@ -22,8 +28,11 @@ class MainActivity : ComponentActivity() {
shape = RectangleShape
) {
SharedNavHost()
+
+
}
}
+ MediaChangeListener(onMediaChange = viewModel::onMediaChange)
}
}
}
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/MainViewModel.kt b/app-default/src/main/java/moe/tabidachi/emulator/MainViewModel.kt
new file mode 100644
index 0000000..337ca00
--- /dev/null
+++ b/app-default/src/main/java/moe/tabidachi/emulator/MainViewModel.kt
@@ -0,0 +1,25 @@
+package moe.tabidachi.emulator
+
+import android.content.Intent
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import moe.tabidachi.emulator.domain.GetSdcardPathsUseCase
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val getSdcardPathsUseCase: GetSdcardPathsUseCase,
+) : ViewModel() {
+ fun onMediaChange(action: String) {
+ viewModelScope.launch {
+ when (action) {
+ Intent.ACTION_MEDIA_MOUNTED -> getSdcardPathsUseCase()
+ Intent.ACTION_MEDIA_UNMOUNTED -> getSdcardPathsUseCase()
+ Intent.ACTION_MEDIA_EJECT -> Unit
+ Intent.ACTION_MEDIA_REMOVED -> Unit
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/data/common/MenuToggleItem.kt b/app-default/src/main/java/moe/tabidachi/emulator/data/common/MenuToggleItem.kt
new file mode 100644
index 0000000..657548b
--- /dev/null
+++ b/app-default/src/main/java/moe/tabidachi/emulator/data/common/MenuToggleItem.kt
@@ -0,0 +1,109 @@
+package moe.tabidachi.emulator.data.common
+
+import android.util.Log
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.io.IOException
+import moe.tabidachi.emulator.common.MenuToggle
+import moe.tabidachi.emulator.common.ktx.TAG
+import java.io.File
+import java.io.FileInputStream
+import java.io.InputStream
+import java.io.PrintWriter
+
+class MenuToggleItem(
+ val path: String,
+ private val scope: CoroutineScope
+) {
+ private val map = mutableMapOf()
+ private val _input_menu_toggle_gamepad_combo = mutableStateOf(MenuToggle.None)
+
+ val value by _input_menu_toggle_gamepad_combo
+
+ init {
+ scope.launch {
+ try {
+ open(path)
+ val menuToggle =
+ MenuToggle.entries.firstOrNull { it.value == getInt(KEY) } ?: MenuToggle.None
+ _input_menu_toggle_gamepad_combo.value = menuToggle
+ } catch (ioe: IOException) {
+ Log.e(
+ TAG,
+ "Stream reading the configuration file was suddenly closed for an unknown reason."
+ )
+ }
+ }
+ }
+
+ private suspend fun open(configPath: String) = withContext(Dispatchers.IO) {
+ clear()
+ FileInputStream(configPath).use { stream ->
+ append(stream)
+ }
+ }
+
+ private fun append(stream: InputStream) {
+ stream.bufferedReader().use { reader ->
+ reader.forEachLine { line ->
+ parseLine(line)
+ }
+ }
+ }
+
+ private fun parseLine(line: String) {
+ val tokens = line.split("=", limit = 2)
+ if (tokens.size < 2) return
+
+ val key = tokens[0].trim()
+ var value = tokens[1].trim()
+
+ value = if (value.startsWith("\"")) {
+ value.substring(1, value.lastIndexOf('"'))
+ } else {
+ value.split(" ")[0]
+ }
+
+ if (value.isNotEmpty()) {
+ map[key] = value
+ }
+ }
+
+ private fun clear() {
+ map.clear()
+ }
+
+ fun update(menu: MenuToggle) {
+ _input_menu_toggle_gamepad_combo.value = menu
+ setInt(KEY, menu.value)
+ scope.launch {
+ write(path)
+ }
+ }
+
+ private fun setInt(key: String, value: Int) {
+ map[key] = value.toString()
+ }
+
+ private fun getString(key: String): String? = map[key]
+
+ private fun getInt(key: String): Int? = getString(key)?.toIntOrNull()
+
+ private suspend fun write(path: String) = withContext(Dispatchers.IO) {
+ val file = File(path)
+ file.parentFile?.mkdirs()
+ PrintWriter(file).use { writer ->
+ map.forEach { (key, value) ->
+ writer.println("$key = \"$value\"")
+ }
+ }
+ }
+
+ companion object {
+ private const val KEY = "input_menu_toggle_gamepad_combo"
+ }
+}
\ No newline at end of file
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/data/common/StringMatcher.kt b/app-default/src/main/java/moe/tabidachi/emulator/data/common/StringMatcher.kt
new file mode 100644
index 0000000..61870f5
--- /dev/null
+++ b/app-default/src/main/java/moe/tabidachi/emulator/data/common/StringMatcher.kt
@@ -0,0 +1,55 @@
+package moe.tabidachi.emulator.data.common
+
+import kotlin.math.abs
+
+class StringMatcher(
+ private val search: String,
+ private val charSetWeight: Double = 0.2,
+ private val containsWeight: Double = 0.6,
+ private val lengthWeight: Double = 0.2
+) {
+ private val searchChars: Set = search.toSet()
+
+ init {
+ require(charSetWeight + containsWeight + lengthWeight == 1.0) { "权重之和必须为 1.0" }
+ }
+
+ fun similarity(target: String): Double {
+ if (!containsAnyChar(target)) return 0.0
+
+ val charSetSimilarity = calculateCharSetSimilarity(target)
+
+ val containsSimilarity = if (contains(target)) 1.0 else 0.0
+
+ val lengthSimilarity = calculateLengthSimilarity(target)
+
+ return charSetSimilarity * charSetWeight +
+ containsSimilarity * containsWeight +
+ lengthSimilarity * lengthWeight
+ }
+
+ private fun containsAnyChar(target: String): Boolean {
+ return target.any { it in searchChars }
+ }
+
+ private fun calculateCharSetSimilarity(target: String): Double {
+ val targetChars = target.toSet()
+ val missingChars = searchChars.count { it !in targetChars }
+
+ return when (missingChars) {
+ searchChars.size -> 0.0
+ else -> (searchChars.size - missingChars).toDouble() / searchChars.size
+ }
+ }
+
+ private fun contains(target: String): Boolean {
+ return target.replace(" ", "").contains(search.replace(" ", ""), true)
+ }
+
+ private fun calculateLengthSimilarity(target: String): Double {
+ val searchLength = search.length.toDouble()
+ val targetLength = target.length.toDouble()
+
+ return 1 - (abs(targetLength - searchLength) / maxOf(targetLength, searchLength))
+ }
+}
\ No newline at end of file
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/SharedNavHost.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/SharedNavHost.kt
index 46bb7f7..577cff1 100644
--- a/app-default/src/main/java/moe/tabidachi/emulator/ui/SharedNavHost.kt
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/SharedNavHost.kt
@@ -10,6 +10,7 @@ import moe.tabidachi.emulator.ui.roms.roms
@Composable
fun SharedNavHost(
+ //startDestination: Any = RomsRoute("/storage/12BF-EFB1/psp/emulator.json")
startDestination: Any = HomeRoute
) {
val navController: NavHostController = rememberNavController()
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/ActionIcon.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/ActionIcon.kt
new file mode 100644
index 0000000..083a704
--- /dev/null
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/ActionIcon.kt
@@ -0,0 +1,32 @@
+package moe.tabidachi.emulator.ui.common
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil3.compose.AsyncImage
+import moe.tabidachi.emulator.data.ActionIcon
+
+@Composable
+fun ActionIcon(
+ actionIcon: ActionIcon,
+) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ AsyncImage(
+ model = actionIcon.iconFile,
+ contentDescription = null,
+ colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.onPrimaryContainer),
+ modifier = Modifier.size(24.dp)
+ )
+ Text(text = actionIcon.label)
+ }
+}
\ No newline at end of file
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/BottomBar.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/BottomBar.kt
index d89b4ba..1ec2d0b 100644
--- a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/BottomBar.kt
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/BottomBar.kt
@@ -16,7 +16,8 @@ import androidx.tv.material3.Text
@Composable
fun GamepadIndicatorBar(
- leadingContent: @Composable (() -> Unit)? = null
+ leadingContent: (@Composable () -> Unit)? = null,
+ trailingContent: (@Composable () -> Unit)? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -24,10 +25,11 @@ fun GamepadIndicatorBar(
.background(color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f))
.padding(8.dp)
) {
- ProvideTextStyle(MaterialTheme.typography.titleLarge) {
+ ProvideTextStyle(MaterialTheme.typography.labelLarge) {
leadingContent?.invoke()
}
Spacer(modifier = Modifier.weight(1f))
+ trailingContent?.invoke()
}
}
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/CoreSelectDialog.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/CoreSelectDialog.kt
new file mode 100644
index 0000000..fdade2d
--- /dev/null
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/CoreSelectDialog.kt
@@ -0,0 +1,76 @@
+package moe.tabidachi.emulator.ui.common
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Checkbox
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.ListItemDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import kotlinx.coroutines.launch
+import moe.tabidachi.emulator.R
+import moe.tabidachi.emulator.data.Emulator
+import moe.tabidachi.emulator.ui.components.TvDialog
+
+@Composable
+fun CoreSelectDialog(
+ visible: Boolean,
+ emulator: Emulator,
+ onDismissRequest: () -> Unit,
+) {
+ if (visible) TvDialog(
+ onDismissRequest = onDismissRequest
+ ) {
+ val scope = rememberCoroutineScope()
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier
+ .padding(16.dp)
+ .heightIn(min = 64.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.core_select),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(start = 16.dp)
+ )
+ if (emulator.config.corePaths.isNotEmpty()) {
+ var currentCore by remember { mutableStateOf(emulator.config.corePaths.first()) }
+ LazyColumn {
+ items(emulator.config.corePaths) {
+ val selected = currentCore == it
+ ListItem(
+ selected = selected,
+ onClick = {
+ currentCore = it
+ scope.launch {
+ emulator.setCore(it)
+ }
+ }, headlineContent = {
+ Text(text = it)
+ }, scale = ListItemDefaults.scale(focusedScale = 1.0f),
+ trailingContent = {
+ Checkbox(
+ checked = selected,
+ onCheckedChange = {}
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/ExitDialog.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/ExitDialog.kt
new file mode 100644
index 0000000..810ed21
--- /dev/null
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/ExitDialog.kt
@@ -0,0 +1,38 @@
+package moe.tabidachi.emulator.ui.common
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.tv.material3.Text
+import androidx.tv.material3.WideButton
+import moe.tabidachi.emulator.R
+import moe.tabidachi.emulator.ui.components.TvAlertDialog
+
+@Composable
+fun ExitDialog(
+ visible: Boolean,
+ onConfirm: () -> Unit,
+ onDismissRequest: () -> Unit,
+) {
+ if (visible) TvAlertDialog(
+ onDismissRequest = onDismissRequest,
+ title = {
+ Text(text = stringResource(R.string.exit_dialog_title))
+ }, text = {
+ Text(text = stringResource(R.string.exit_dialog_content))
+ }, confirmButton = {
+ WideButton(
+ onClick = onConfirm
+ ) {
+ Text(text = stringResource(R.string.dialog_confirm))
+ }
+ }, dismissButton = {
+ WideButton(
+ onClick = {
+ onDismissRequest()
+ }
+ ) {
+ Text(text = stringResource(R.string.dialog_cancel))
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/MenuToggleDialog.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/MenuToggleDialog.kt
new file mode 100644
index 0000000..0adee26
--- /dev/null
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/MenuToggleDialog.kt
@@ -0,0 +1,105 @@
+package moe.tabidachi.emulator.ui.common
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Checkbox
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.FilterChip
+import androidx.tv.material3.FilterChipDefaults
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.ListItemDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import moe.tabidachi.emulator.R
+import moe.tabidachi.emulator.common.MenuToggle
+import moe.tabidachi.emulator.data.common.MenuToggleItem
+import moe.tabidachi.emulator.ui.components.TvDialog
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun MenuToggleDialog(
+ visible: Boolean,
+ menuToggleItems: List,
+ onDismissRequest: () -> Unit,
+) {
+ if (visible) TvDialog(
+ onDismissRequest = onDismissRequest
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier
+ .padding(16.dp)
+ .heightIn(min = 64.dp)
+ .fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(R.string.menu_toggle),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(start = 16.dp)
+ )
+ if (menuToggleItems.isNotEmpty()) {
+ var menuToggleItem by remember { mutableStateOf(menuToggleItems.first()) }
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ items(menuToggleItems) {
+ FilterChip(
+ selected = menuToggleItem == it,
+ onClick = {
+ menuToggleItem = it
+ }, scale = FilterChipDefaults.scale(focusedScale = 1f)
+ ) {
+ Text(text = it.path)
+ }
+ }
+ }
+ LazyColumn(
+ modifier = Modifier.weight(1f)
+ ) {
+ items(MenuToggle.entries) {
+ val selected = menuToggleItem.value == it
+ ListItem(
+ selected = selected,
+ onClick = {
+ menuToggleItem.update(it)
+ },
+ headlineContent = {
+ Text(text = it.text)
+ }, scale = ListItemDefaults.scale(focusedScale = 1.0f),
+ trailingContent = {
+ Checkbox(
+ checked = selected,
+ onCheckedChange = {}
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@TvPreview
+@Composable
+fun MenuToggleDialogPreview() {
+ MenuToggleDialog(
+ visible = true,
+ menuToggleItems = emptyList(),
+ onDismissRequest = {}
+ )
+}
\ No newline at end of file
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/SearchDialog.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/SearchDialog.kt
new file mode 100644
index 0000000..087e080
--- /dev/null
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/SearchDialog.kt
@@ -0,0 +1,285 @@
+package moe.tabidachi.emulator.ui.common
+
+import android.util.Log
+import android.view.KeyEvent
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ClickableSurfaceDefaults
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.ListItemDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import coil3.compose.AsyncImage
+import moe.tabidachi.emulator.R
+import moe.tabidachi.emulator.common.ktx.TAG
+import moe.tabidachi.emulator.data.Emulator
+import moe.tabidachi.emulator.data.Rom
+import moe.tabidachi.emulator.ui.components.TvDialog
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun SearchDialog(
+ visible: Boolean,
+ roms: List>,
+ searching: Boolean,
+ query: String,
+ onLaunch: (Emulator, Rom) -> Unit,
+ onQueryChange: (String) -> Unit,
+ onDismissRequest: () -> Unit,
+) {
+ if (visible) TvDialog(
+ onDismissRequest = onDismissRequest,
+ parent = {
+ it.align(Alignment.TopCenter)
+ }
+ ) {
+ val listFocusRequester = remember { FocusRequester() }
+ val clearFocusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+ Column(
+ //verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.padding(horizontal = 16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.search),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier
+ )
+ SearchTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = Modifier
+ .weight(1f)
+ .onKeyEvent {
+ Log.d(TAG, "SearchDialog: $it")
+ when (it.nativeKeyEvent.keyCode) {
+ KeyEvent.KEYCODE_DPAD_DOWN -> {
+ listFocusRequester.requestFocus()
+ true
+ }
+
+ KeyEvent.KEYCODE_DPAD_CENTER -> {
+ keyboardController?.show()
+ false
+ }
+
+ KeyEvent.KEYCODE_DPAD_RIGHT -> {
+ clearFocusRequester.requestFocus()
+ true
+ }
+
+ else -> false
+ }
+ }
+ )
+ Surface(
+ onClick = {
+ onQueryChange("")
+ }, shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
+ modifier = Modifier.focusRequester(clearFocusRequester),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Text(
+ text = "Clear",
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
+ )
+ }
+ }
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.height(16.dp)
+ ) {
+ if (searching) LinearProgressIndicator(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ )
+ }
+ CompositionLocalProvider(
+ LocalBringIntoViewSpec provides rememberPivotBringIntoViewSpec()
+ ) {
+ LazyColumn(
+ modifier = Modifier.focusRequester(listFocusRequester)
+ ) {
+ items(roms) { (emulator, rom) ->
+ ListItem(
+ selected = false,
+ onClick = {
+ onLaunch(emulator, rom)
+ },
+ headlineContent = {
+ Text(text = rom.name)
+ },
+ scale = ListItemDefaults.scale(focusedScale = 1.0f),
+ trailingContent = {
+ Text(text = emulator.title)
+ }, leadingContent = {
+ AsyncImage(
+ model = rom.imageFile,
+ contentDescription = null,
+ modifier = Modifier.size(40.dp)
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SearchDialog(
+ visible: Boolean,
+ searching: Boolean,
+ query: String,
+ onQueryChange: (String) -> Unit,
+ onDismissRequest: () -> Unit,
+) {
+ if (visible) TvDialog(
+ onDismissRequest = onDismissRequest,
+ parent = {
+ it.align(Alignment.Center)
+ }
+ ) {
+ val clearFocusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.padding(horizontal = 16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.search),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier
+ )
+ SearchTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = Modifier
+ .weight(1f)
+ .onKeyEvent {
+ Log.d(TAG, "SearchDialog: $it")
+ when (it.nativeKeyEvent.keyCode) {
+ KeyEvent.KEYCODE_DPAD_CENTER -> {
+ keyboardController?.show()
+ false
+ }
+
+ KeyEvent.KEYCODE_DPAD_RIGHT -> {
+ clearFocusRequester.requestFocus()
+ true
+ }
+
+ else -> false
+ }
+ }
+ )
+ Surface(
+ onClick = {
+ onQueryChange("")
+ }, shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
+ scale = ClickableSurfaceDefaults.scale(focusedScale = 1f),
+ modifier = Modifier.focusRequester(clearFocusRequester),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Text(
+ text = "Clear",
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
+ )
+ }
+ }
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.height(16.dp)
+ ) {
+ if (searching) LinearProgressIndicator(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun SearchTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ textStyle = MaterialTheme.typography.bodyLarge.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ decorationBox = { innerTextField ->
+ Box(
+ modifier = Modifier
+ .border(
+ width = 2.dp,
+ color = if (isFocused) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.surfaceVariant
+ )
+ .background(color = MaterialTheme.colorScheme.surfaceVariant)
+ .padding(8.dp)
+ ) {
+ innerTextField()
+ }
+ }, singleLine = true,
+ interactionSource = interactionSource,
+ modifier = modifier
+ )
+}
\ No newline at end of file
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/SettingsDialog.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/SettingsDialog.kt
new file mode 100644
index 0000000..a388728
--- /dev/null
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/SettingsDialog.kt
@@ -0,0 +1,62 @@
+package moe.tabidachi.emulator.ui.common
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Icon
+import androidx.tv.material3.ListItem
+import androidx.tv.material3.ListItemDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import moe.tabidachi.emulator.R
+import moe.tabidachi.emulator.ui.components.TvDialog
+
+@Composable
+fun SettingsDialog(
+ visible: Boolean,
+ actions: SettingsActions,
+ onDismissRequest: () -> Unit
+) {
+ if (visible) TvDialog(
+ onDismissRequest = onDismissRequest,
+ parent = {
+ it.align(Alignment.TopCenter)
+ }
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.settings),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(start = 16.dp)
+ )
+ ListItem(
+ selected = false,
+ onClick = actions.onMenuToggleClick,
+ headlineContent = {
+ Text(text = stringResource(R.string.menu_toggle))
+ },
+ trailingContent = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ contentDescription = null
+ )
+ },
+ scale = ListItemDefaults.scale(focusedScale = 1.0f),
+ )
+ }
+ }
+}
+
+data class SettingsActions(
+ val onMenuToggleClick: () -> Unit = {}
+)
\ No newline at end of file
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeRoute.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeRoute.kt
index dcb7568..bd4a19d 100644
--- a/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeRoute.kt
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeRoute.kt
@@ -10,7 +10,6 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import kotlinx.serialization.Serializable
-import moe.tabidachi.emulator.ui.common.MediaChangeListener
import moe.tabidachi.emulator.ui.roms.navigateToRoms
fun NavGraphBuilder.home(navController: NavHostController) = composable {
@@ -21,11 +20,13 @@ fun NavGraphBuilder.home(navController: NavHostController) = composable(null) }
+ var settingsDialogVisible by remember { mutableStateOf(false) }
+ val actionIconMap = state.actionIcons.associateBy { it.keycode }
+ var menuToggleDialogVisible by remember { mutableStateOf(false) }
+ var searchDialogVisible by remember { mutableStateOf(false) }
+ var exitDialogVisible by remember { mutableStateOf(false) }
+ val focusRequester = remember { FocusRequester() }
+ BackHandler { exitDialogVisible = true }
Box(
- modifier = Modifier.fillMaxSize()
+ modifier = Modifier
+ .fillMaxSize()
+ .onPreviewKeyEvent {
+ val actionIcon = actionIconMap[it.nativeKeyEvent.keyCode]
+ if (it.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) actionIcon?.let {
+ return@onPreviewKeyEvent when (it.type) {
+ ActionIcon.Type.SETTINGS -> {
+ settingsDialogVisible = true
+ true
+ }
+
+ ActionIcon.Type.SEARCH -> {
+ searchDialogVisible = true
+ true
+ }
+
+ else -> false
+ }
+ }
+ false
+ }
) {
HorizontalPager(
state = pagerState
@@ -82,7 +133,7 @@ fun HomeScreen(
Spacer(modifier = Modifier.weight(1f))
CompositionLocalProvider(
LocalBringIntoViewSpec provides rememberPivotBringIntoViewSpec(
- parentFraction = 0.25f,
+ parentFraction = 0.2f,
)
) {
LazyRow(
@@ -91,6 +142,7 @@ fun HomeScreen(
contentPadding = PaddingValues(vertical = 16.dp),
modifier = Modifier
.focusRestorer()
+ .focusRequester(focusRequester)
) {
if (size > 0) items(
count = pageCount,
@@ -116,7 +168,10 @@ fun HomeScreen(
color = Color(0xFFFF9C40)
)
)
- ), modifier = Modifier.size(200.dp)
+ ),
+ modifier = Modifier
+ .fillMaxHeight(0.25f)
+ .aspectRatio(AspectRatio.AR1_1.value)
) {
AsyncImage(
model = item.icon,
@@ -129,12 +184,48 @@ fun HomeScreen(
GamepadIndicatorBar(
leadingContent = {
Text(text = stringResource(R.string.game_size, focusedItem?.romsSize ?: 0))
+ }, trailingContent = {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ state.actionIcons.forEach {
+ ActionIcon(it)
+ }
+ }
}
)
}
+ val backgroundColor = Color.Black.copy(alpha = 0.3f)
+ CompositionLocalProvider(
+ LocalContentColor provides Color.White
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .background(color = backgroundColor)
+ .padding(horizontal = 8.dp)
+ .fillMaxWidth()
+ ) {
+ TimeText(contentPadding = PaddingValues(vertical = 4.dp))
+ Spacer(modifier = Modifier.weight(1f))
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .padding(4.dp)
+ ) { EthernetStatusIcon() }
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .padding(4.dp)
+ ) { WifiStatusIcon() }
+ }
+ }
}
LaunchedEffect(state.emulatorList) {
- if (state.emulatorList.isNotEmpty()) listState.animateScrollToItem(listState.firstVisibleItemIndex)
+ if (state.emulatorList.isNotEmpty()) {
+ //listState.animateScrollToItem(listState.firstVisibleItemIndex)
+ focusRequester.requestFocus()
+ }
}
NoSdcardDialog(
visible = state.isSdcardEmpty,
@@ -154,6 +245,41 @@ fun HomeScreen(
onGranted = actions.onPermissionGranted,
onDenied = actions.onActivityFinished
)
+ MenuToggleDialog(
+ visible = menuToggleDialogVisible,
+ menuToggleItems = state.menuToggleItems,
+ onDismissRequest = {
+ menuToggleDialogVisible = false
+ },
+ )
+ SearchDialog(
+ visible = searchDialogVisible,
+ roms = state.queryRoms,
+ searching = state.searching,
+ query = state.query,
+ onLaunch = actions.onGameLaunch,
+ onQueryChange = actions.onQueryChange,
+ onDismissRequest = {
+ searchDialogVisible = false
+ }
+ )
+ SettingsDialog(
+ visible = settingsDialogVisible,
+ actions = remember {
+ SettingsActions(
+ onMenuToggleClick = {
+ menuToggleDialogVisible = true
+ }
+ )
+ }, onDismissRequest = {
+ settingsDialogVisible = false
+ }
+ )
+ ExitDialog(
+ visible = exitDialogVisible,
+ onConfirm = actions.onExit,
+ onDismissRequest = { exitDialogVisible = false }
+ )
}
@TvPreview
@@ -164,7 +290,7 @@ private fun HomeScreenPreview() {
state = HomeViewState(
isAssetsExtracting = false,
progress = 0.5f,
- permissionDialogVisible = true
+ permissionDialogVisible = false
),
actions = HomeActions()
)
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeViewModel.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeViewModel.kt
index 35b9595..c4bb258 100644
--- a/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeViewModel.kt
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeViewModel.kt
@@ -1,27 +1,39 @@
package moe.tabidachi.emulator.ui.home
-import android.content.Intent
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import moe.tabidachi.emulator.common.GameLauncher
import moe.tabidachi.emulator.common.ktx.TAG
+import moe.tabidachi.emulator.data.ActionIcon
import moe.tabidachi.emulator.data.AssetsState
+import moe.tabidachi.emulator.data.Emulator
import moe.tabidachi.emulator.data.EmulatorListItem
+import moe.tabidachi.emulator.data.EmulatorRepository
import moe.tabidachi.emulator.data.PermissionState
+import moe.tabidachi.emulator.data.Rom
+import moe.tabidachi.emulator.data.common.MenuToggleItem
+import moe.tabidachi.emulator.data.common.StringMatcher
import moe.tabidachi.emulator.di.AssetsStateFlow
import moe.tabidachi.emulator.di.PermissionStateFlow
-import moe.tabidachi.emulator.di.SdcardPaths
import moe.tabidachi.emulator.domain.CheckPermissionUseCase
import moe.tabidachi.emulator.domain.ExtractAssetsUseCase
import moe.tabidachi.emulator.domain.GetAssetsFileUseCase
@@ -37,76 +49,127 @@ class HomeViewModel @Inject constructor(
private val assetsState: MutableStateFlow,
@PermissionStateFlow
private val permissionState: MutableStateFlow,
- @SdcardPaths
- private val sdcardPaths: MutableStateFlow>,
private val checkPermissionUseCase: CheckPermissionUseCase,
private val getSdcardPathsUseCase: GetSdcardPathsUseCase,
getAssetsFileUseCase: GetAssetsFileUseCase,
private val extractAssetsUseCase: ExtractAssetsUseCase,
- private val getEmulatorListUseCase: GetEmulatorListUseCase
+ private val getEmulatorListUseCase: GetEmulatorListUseCase,
+ private val emulatorRepository: EmulatorRepository,
+ private val gameLauncher: GameLauncher
) : ViewModel() {
private val _state = MutableStateFlow(HomeViewState())
val state = _state.asStateFlow()
val actions = HomeActions(
- onMediaChange = ::onMediaChange,
onExtractDialogVisibleChange = ::onExtractDialogVisibleChange,
onPermissionDialogVisibleChange = ::onPermissionDialogVisibleChange,
- onPermissionGranted = ::onPermissionGranted
- )
-
- init {
- permissionState.onEach {
- when (it) {
- PermissionState.None -> checkPermissionUseCase()
- PermissionState.Granted -> getSdcardPathsUseCase()
- PermissionState.Denied -> _state.update { it.copy(permissionDialogVisible = true) }
- }
- }.launchIn(viewModelScope)
- assetsState.onEach {
- if (it == AssetsState.None) getAssetsStateUseCase()
- }.launchIn(viewModelScope)
- combine(
- assetsState,
- getAssetsFileUseCase(),
- ) { assetsState, file ->
- assetsState to file
- }.onEach { (assetsState, file) ->
- Log.d(TAG, "$assetsState, getAssetsFileUseCase($file)")
- if (assetsState == AssetsState.NotExtracted) {
- try {
- _state.update { it.copy(isAssetsExtracting = true) }
- extractAssetsUseCase(
- file,
- onProgress = { progress ->
- _state.update { it.copy(progress = progress) }
- },
- )
- } catch (e: Throwable) {
- Log.d(TAG, "解压失败", e)
- } finally {
- _state.update { it.copy(isAssetsExtracting = false) }
- }
- }
- }.launchIn(viewModelScope)
- viewModelScope.launch(Dispatchers.IO) {
- getEmulatorListUseCase().collect { emulatorList: List ->
- _state.update { it.copy(emulatorList = emulatorList) }
+ onPermissionGranted = ::onPermissionGranted,
+ onQueryChange = { query ->
+ _state.update { it.copy(query = query) }
+ }, onGameLaunch = { emulator, rom ->
+ viewModelScope.launch {
+ gameLauncher.launch(emulator, rom)
}
}
- combine(permissionState, sdcardPaths) { permissionState, sdcardPaths ->
- permissionState to sdcardPaths
- }.filter { it.first == PermissionState.Granted }.map { it.second.isEmpty() }
- .onEach { isSdcardEmpty ->
- _state.update { it.copy(isSdcardEmpty = isSdcardEmpty) }
- }.launchIn(viewModelScope)
- }
+ )
+ private val menuToggleItems = emulatorRepository.storageList.map {
+ it.map {
+ MenuToggleItem(it.retroarchConfigFile.path, viewModelScope)
+ }
+ }.onEach { menuToggleItems ->
+ _state.update { it.copy(menuToggleItems = menuToggleItems) }
+ }.flowOn(Dispatchers.Default).stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Lazily,
+ initialValue = emptyList()
+ )
+ private val actionIcons = emulatorRepository.storageList.mapNotNull {
+ it.firstOrNull()?.actionIcons?.takeIf { it.isNotEmpty() }
+ }.onEach { actionIcons ->
+ _state.update { it.copy(actionIcons = actionIcons) }
+ }.flowOn(Dispatchers.Default).stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Lazily,
+ initialValue = emptyList()
+ )
- private fun onMediaChange(action: String) {
- when (action) {
- Intent.ACTION_MEDIA_MOUNTED -> getSdcardPathsUseCase()
- Intent.ACTION_MEDIA_UNMOUNTED -> getSdcardPathsUseCase()
- Intent.ACTION_MEDIA_EJECT -> Unit
- Intent.ACTION_MEDIA_REMOVED -> Unit
+ @OptIn(FlowPreview::class)
+ private val roms = _state
+ .map { it.query }
+ .distinctUntilChanged()
+ .debounce(2000)
+ .combine(emulatorRepository.emulatorList) { query, emulatorList ->
+ _state.update { it.copy(searching = true) }
+ val matcher = StringMatcher(query)
+ when (query.isBlank()) {
+ true -> emulatorList.flatMap { emulator ->
+ emulator.roms.map { emulator to it }
+ }
+
+ else -> emulatorList.flatMap { emulator ->
+ emulator.roms.map {
+ matcher.similarity(it.name) to (emulator to it)
+ }.filter { it.first > 0.3 }
+ }.sortedByDescending {
+ it.first
+ }.map { it.second }
+ }
+ }.onEach { queryRoms ->
+ _state.update { it.copy(queryRoms = queryRoms, searching = false) }
+ }.flowOn(Dispatchers.Default).stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Lazily,
+ initialValue = emptyList()
+ )
+
+ init {
+ viewModelScope.launch(Dispatchers.Default) {
+ permissionState.onEach {
+ when (it) {
+ PermissionState.None -> checkPermissionUseCase()
+ PermissionState.Granted -> getSdcardPathsUseCase()
+ PermissionState.Denied -> _state.update { it.copy(permissionDialogVisible = true) }
+ }
+ }.launchIn(this)
+ assetsState.onEach {
+ if (it == AssetsState.None) getAssetsStateUseCase()
+ }.launchIn(this)
+ combine(
+ assetsState,
+ getAssetsFileUseCase(),
+ ) { assetsState, file ->
+ assetsState to file
+ }.onEach { (assetsState, file) ->
+ Log.d(TAG, "$assetsState, getAssetsFileUseCase($file)")
+ if (assetsState == AssetsState.NotExtracted) {
+ try {
+ _state.update { it.copy(isAssetsExtracting = true) }
+ extractAssetsUseCase(
+ file,
+ onProgress = { progress ->
+ _state.update { it.copy(progress = progress) }
+ },
+ )
+ } catch (e: Throwable) {
+ Log.d(TAG, "解压失败", e)
+ } finally {
+ _state.update { it.copy(isAssetsExtracting = false) }
+ }
+ }
+ }.launchIn(this)
+ launch(Dispatchers.IO) {
+ getEmulatorListUseCase().collect { emulatorList: List ->
+ _state.update { it.copy(emulatorList = emulatorList) }
+ }
+ }
+ combine(permissionState, emulatorRepository.storageList) { permissionState, storages ->
+ permissionState to storages
+ }.filter { it.first == PermissionState.Granted }.map { it.second.isEmpty() }
+ .onEach { isSdcardEmpty ->
+ _state.update { it.copy(isSdcardEmpty = isSdcardEmpty) }
+ }.launchIn(this)
+ menuToggleItems.launchIn(this)
+ actionIcons.launchIn(this)
+ roms.launchIn(this)
}
}
@@ -130,14 +193,21 @@ data class HomeViewState(
val progress: Float = 0.0f,
val permissionDialogVisible: Boolean = false,
val emulatorList: List = emptyList(),
- val isSdcardEmpty: Boolean = false
+ val isSdcardEmpty: Boolean = false,
+ val menuToggleItems: List = emptyList(),
+ val actionIcons: List = emptyList(),
+ val query: String = "",
+ val queryRoms: List> = emptyList(),
+ val searching: Boolean = false
)
data class HomeActions(
- val onMediaChange: (String) -> Unit = {},
val onExtractDialogVisibleChange: (Boolean) -> Unit = {},
val onPermissionDialogVisibleChange: (Boolean) -> Unit = {},
val onPermissionGranted: () -> Unit = {},
val onActivityFinished: () -> Unit = {},
val navigateToRoms: (id: String) -> Unit = {},
+ val onQueryChange: (String) -> Unit = {},
+ val onGameLaunch: (Emulator, Rom) -> Unit = { _, _ -> },
+ val onExit: () -> Unit = {}
)
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsRoute.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsRoute.kt
index 7a98a70..2ceffc5 100644
--- a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsRoute.kt
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsRoute.kt
@@ -12,14 +12,12 @@ import kotlinx.serialization.Serializable
fun NavGraphBuilder.roms(navController: NavHostController) = composable {
val viewModel: RomsViewModel = hiltViewModel()
val state by viewModel.state.collectAsState()
- val actions = remember(viewModel.actions) {
- viewModel.actions
- }
+ val actions = viewModel.actions
RomsScreen(state = state, actions = actions)
}
@Serializable
-data class RomsRoute(val id: String)
+data class RomsRoute(val emulatorId: String)
fun NavHostController.navigateToRoms(id: String) {
navigate(RomsRoute(id))
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsScreen.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsScreen.kt
index 2278235..1078c08 100644
--- a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsScreen.kt
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsScreen.kt
@@ -1,15 +1,20 @@
package moe.tabidachi.emulator.ui.roms
+import android.view.KeyEvent
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -18,19 +23,29 @@ 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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusTarget
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ListItem
+import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import moe.tabidachi.emulator.common.ktx.digits
+import moe.tabidachi.emulator.data.ActionIcon
import moe.tabidachi.emulator.data.RomListItem
+import moe.tabidachi.emulator.ui.common.ActionIcon
+import moe.tabidachi.emulator.ui.common.CoreSelectDialog
+import moe.tabidachi.emulator.ui.common.GamepadIndicatorBar
import moe.tabidachi.emulator.ui.common.PreviewSurface
+import moe.tabidachi.emulator.ui.common.SearchDialog
import moe.tabidachi.emulator.ui.common.TvPreview
import moe.tabidachi.emulator.ui.common.rememberPivotBringIntoViewSpec
@@ -41,49 +56,146 @@ fun RomsScreen(
actions: RomsActions
) {
var focusedItem by remember { mutableStateOf(null) }
-
+ var settingsDialogVisible by remember { mutableStateOf(false) }
+ var searchDialogVisible by remember { mutableStateOf(false) }
+ val actionIconMap = state.actionIcons.associateBy { it.keycode }
+ val focusRequester = remember { FocusRequester() }
Box(
- modifier = Modifier.fillMaxSize()
- ) {
- Row {
- CompositionLocalProvider(
- LocalBringIntoViewSpec provides rememberPivotBringIntoViewSpec()
- ) {
- LazyColumn(
- contentPadding = PaddingValues(16.dp),
- modifier = Modifier.weight(1f)
- ) {
- itemsIndexed(state.roms) { index, rom ->
- val interactionSource = remember { MutableInteractionSource() }
- val isFocused by interactionSource.collectIsFocusedAsState()
- LaunchedEffect(isFocused) {
- if (isFocused) focusedItem = rom
+ modifier = Modifier
+ .fillMaxSize()
+ .onPreviewKeyEvent {
+ val actionIcon = actionIconMap[it.nativeKeyEvent.keyCode]
+ if (it.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) actionIcon?.let {
+ return@onPreviewKeyEvent when (it.type) {
+ ActionIcon.Type.SETTINGS -> {
+ settingsDialogVisible = true
+ true
}
- ListItem(
- selected = false,
- headlineContent = {
- val text = buildAnnotatedString {
- withStyle(MaterialTheme.typography.labelSmall.toSpanStyle()) {
- append("%0${state.roms.size.digits}d".format(index + 1))
- }
- append(" ")
- append(rom.name)
- }
- Text(text = text)
- }, onClick = {
- }, interactionSource = interactionSource
- )
+ ActionIcon.Type.SEARCH -> {
+ searchDialogVisible = true
+ true
+ }
+
+ ActionIcon.Type.FAVORITE -> {
+ true
+ }
+
+ else -> false
}
}
+ false
}
- AsyncImage(
- model = focusedItem?.imagePath,
- contentDescription = null,
- modifier = Modifier.weight(1f)
+ ) {
+ AsyncImage(
+ model = state.background,
+ contentDescription = null
+ )
+ Column {
+ Row(
+ modifier = Modifier
+ .background(color = Color.Black.copy(alpha = 0.3f))
+ .weight(1f)
+ ) {
+ CompositionLocalProvider(
+ LocalBringIntoViewSpec provides rememberPivotBringIntoViewSpec()
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .padding(16.dp)
+ .background(color = Color.Black.copy(alpha = 0.1f))
+ ) {
+ when (state.roms.isEmpty()) {
+ true -> Text(
+ text = "Empty",
+ modifier = Modifier.align(Alignment.Center).focusTarget()
+ )
+
+ else -> LazyColumn(
+ contentPadding = PaddingValues(16.dp),
+ modifier = Modifier.focusRequester(focusRequester)
+ ) {
+ itemsIndexed(state.roms) { index, rom ->
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ LaunchedEffect(isFocused) {
+ if (isFocused) focusedItem = rom
+ }
+ ListItem(
+ selected = false,
+ headlineContent = {
+ val text = buildAnnotatedString {
+ withStyle(MaterialTheme.typography.labelSmall.toSpanStyle()) {
+ append(
+ "%0${state.roms.size.digits}d".format(
+ index + 1
+ )
+ )
+ }
+ append(" ")
+ append(rom.name)
+ }
+ Text(text = text)
+ }, onClick = {
+ actions.onRomClick(rom.id)
+ }, interactionSource = interactionSource,
+ scale = ListItemDefaults.scale(focusedScale = 1f)
+ )
+ }
+ }
+ }
+ }
+ }
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .padding(16.dp)
+ ) {
+ AsyncImage(
+ model = focusedItem?.imagePath,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+ GamepadIndicatorBar(
+ leadingContent = {
+
+ }, trailingContent = {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ state.actionIcons.forEach {
+ ActionIcon(it)
+ }
+ }
+ }
)
}
}
+ LaunchedEffect(state.roms) {
+ if (state.roms.isNotEmpty()) focusRequester.requestFocus()
+ }
+ state.emulator?.let {
+ CoreSelectDialog(
+ visible = settingsDialogVisible,
+ emulator = it,
+ onDismissRequest = {
+ settingsDialogVisible = false
+ }
+ )
+ }
+ SearchDialog(
+ visible = searchDialogVisible,
+ searching = state.searching,
+ query = state.query,
+ onQueryChange = actions.onQueryChange,
+ onDismissRequest = { searchDialogVisible = false }
+ )
}
@TvPreview
diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsViewModel.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsViewModel.kt
index 703f91b..4cfba7f 100644
--- a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsViewModel.kt
+++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsViewModel.kt
@@ -1,49 +1,129 @@
package moe.tabidachi.emulator.ui.roms
+import android.util.Log
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
-import moe.tabidachi.emulator.data.EmulatorDataSource
+import kotlinx.coroutines.launch
+import moe.tabidachi.emulator.common.GameLauncher
+import moe.tabidachi.emulator.common.ktx.TAG
+import moe.tabidachi.emulator.data.ActionIcon
+import moe.tabidachi.emulator.data.Emulator
+import moe.tabidachi.emulator.data.EmulatorRepository
import moe.tabidachi.emulator.data.RomListItem
+import moe.tabidachi.emulator.data.common.StringMatcher
+import moe.tabidachi.emulator.di.SdcardPaths
+import moe.tabidachi.emulator.domain.GetSdcardPathsUseCase
+import java.io.File
import javax.inject.Inject
@HiltViewModel
class RomsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
- getRomsUseCase: GetRomsUseCase
+ emulatorRepository: EmulatorRepository,
+ @SdcardPaths
+ private val sdcardPaths: MutableStateFlow>,
+ private val getSdcardPathsUseCase: GetSdcardPathsUseCase,
+ private val gameLauncher: GameLauncher
) : ViewModel() {
- private val romId = savedStateHandle.toRoute().id
private val _state = MutableStateFlow(RomsViewState())
val state = _state.asStateFlow()
- val actions = RomsActions()
+ val actions = RomsActions(
+ onRomClick = { romId ->
+ viewModelScope.launch {
+ roms.value.firstOrNull { it.id == romId }?.let { rom ->
+ emulator.firstOrNull()?.let { emulator ->
+ gameLauncher.launch(emulator, rom)
+ }
+ }
+ }
+ }, onQueryChange = { query ->
+ _state.update { it.copy(query = query) }
+ }
+ )
+ private val emulatorId = savedStateHandle.toRoute().emulatorId
+ private val emulator = emulatorRepository.emulatorList.mapNotNull {
+ it.firstOrNull { it.id == emulatorId }
+ }.onEach { emulator ->
+ _state.update {
+ it.copy(
+ background = emulator.backgroundFile,
+ emulator = emulator,
+ actionIcons = emulator.actionIcons
+ )
+ }
+ }.flowOn(Dispatchers.Default).stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Lazily,
+ initialValue = null
+ )
+
+ @OptIn(FlowPreview::class)
+ private val roms = _state
+ .map { it.query }
+ .distinctUntilChanged()
+ .debounce(2000)
+ .combine(emulator) { query, emulator ->
+ emulator?.let { emulator ->
+ _state.update { it.copy(searching = true) }
+ val matcher = StringMatcher(query)
+ when (query.isBlank()) {
+ true -> emulator.roms
+
+ else -> emulator.roms.map {
+ matcher.similarity(it.name) to it
+ }.filter { it.first > 0.3 }.sortedByDescending {
+ it.first
+ }.map { it.second }
+ }
+ }
+ }.mapNotNull { it }.onEach { roms ->
+ val items = roms.map(::RomListItem)
+ _state.update { it.copy(roms = items, searching = false) }
+ }.flowOn(Dispatchers.Default).stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Lazily,
+ initialValue = emptyList()
+ )
init {
- getRomsUseCase(romId).onEach { roms ->
- _state.update { it.copy(roms = roms) }
- }.launchIn(viewModelScope)
+ Log.d(TAG, "emulatorId: $emulatorId")
+ if (sdcardPaths.value.isEmpty()) viewModelScope.launch {
+ getSdcardPathsUseCase()
+ }
+ emulator.launchIn(viewModelScope)
+ roms.launchIn(viewModelScope)
}
}
data class RomsViewState(
val roms: List = emptyList(),
+ val background: File? = null,
+ val emulator: Emulator? = null,
+ val actionIcons: List = emptyList(),
+ val searching: Boolean = false,
+ val query: String = ""
)
data class RomsActions(
- val onClick: () -> Unit = {}
+ val onRomClick: (romId: String) -> Unit = {},
+ val onQueryChange: (String) -> Unit = {}
)
-
-class GetRomsUseCase @Inject constructor(
- private val emulatorDataSource: EmulatorDataSource
-) {
- operator fun invoke(romId: String): Flow> {
- return emulatorDataSource.getRomListByEmulatorId(romId)
- }
-}
\ No newline at end of file
diff --git a/app-default/src/main/res/drawable/ic_launcher_background.xml b/app-default/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..a29a2c2
--- /dev/null
+++ b/app-default/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app-default/src/main/res/drawable/ic_launcher_foreground.xml b/app-default/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..b619620
--- /dev/null
+++ b/app-default/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/app-default/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app-default/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/app-default/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-default/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app-default/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/app-default/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-default/src/main/res/mipmap-hdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-hdpi/ic_launcher.webp
index c209e78..d48637b 100644
Binary files a/app-default/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app-default/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app-default/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app-default/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..5697025
Binary files /dev/null and b/app-default/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app-default/src/main/res/mipmap-mdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-mdpi/ic_launcher.webp
index 4f0f1d6..723cfc2 100644
Binary files a/app-default/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app-default/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app-default/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app-default/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..aba8a17
Binary files /dev/null and b/app-default/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app-default/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-xhdpi/ic_launcher.webp
index 948a307..6a14105 100644
Binary files a/app-default/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app-default/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app-default/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app-default/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..a55ce78
Binary files /dev/null and b/app-default/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app-default/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-xxhdpi/ic_launcher.webp
index 28d4b77..ca73e4c 100644
Binary files a/app-default/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app-default/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app-default/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app-default/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..880629a
Binary files /dev/null and b/app-default/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
index aa7d642..b4d54a3 100644
Binary files a/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..64b5cf1
Binary files /dev/null and b/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app-default/src/main/res/xml/file_paths.xml b/app-default/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..7b281d1
--- /dev/null
+++ b/app-default/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
index c1f2f53..a9521b9 100644
--- a/core/build.gradle.kts
+++ b/core/build.gradle.kts
@@ -63,6 +63,9 @@ dependencies {
api(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
+ api(project(":libretroarch"))
+ api(project(":libppsspp"))
+
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/core/src/main/java/moe/tabidachi/emulator/common/AspectRatio.kt b/core/src/main/java/moe/tabidachi/emulator/common/AspectRatio.kt
new file mode 100644
index 0000000..9f1ac88
--- /dev/null
+++ b/core/src/main/java/moe/tabidachi/emulator/common/AspectRatio.kt
@@ -0,0 +1,7 @@
+package moe.tabidachi.emulator.common
+
+data class AspectRatio(val value: Float) {
+ companion object {
+ val AR1_1 = AspectRatio(1f / 1f)
+ }
+}
diff --git a/core/src/main/java/moe/tabidachi/emulator/common/GameLauncher.kt b/core/src/main/java/moe/tabidachi/emulator/common/GameLauncher.kt
index 131925e..b88dac5 100644
--- a/core/src/main/java/moe/tabidachi/emulator/common/GameLauncher.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/common/GameLauncher.kt
@@ -1,7 +1,90 @@
package moe.tabidachi.emulator.common
-class GameLauncher(
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.Environment
+import android.provider.Settings
+import android.util.Log
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import com.retroarch.browser.retroactivity.RetroActivityFuture
+import dagger.hilt.android.qualifiers.ApplicationContext
+import moe.tabidachi.emulator.R
+import moe.tabidachi.emulator.common.ktx.TAG
+import moe.tabidachi.emulator.common.ktx.toast
+import moe.tabidachi.emulator.common.ktx.unzip
+import moe.tabidachi.emulator.data.Emulator
+import moe.tabidachi.emulator.data.Rom
+import org.ppsspp.ppsspp.NativeActivity
+import org.ppsspp.ppsspp.PpssppActivity
+import java.io.File
+import javax.inject.Inject
+class GameLauncher @Inject constructor(
+ @ApplicationContext
+ val context: Context,
) {
+ private fun ppsspp(emulator: Emulator, file: File) {
+ val uriForFile = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ file
+ )
+ Log.d(TAG, "ppsspp: $uriForFile")
+ val intent = Intent().apply {
+ component = ComponentName(context, PpssppActivity::class.java)
+ setDataAndType(uriForFile, "application/octet-stream")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
+ addCategory(Intent.CATEGORY_DEFAULT)
+ putExtra(NativeActivity.MEMSTICK_DIR_EXTRA_KEY, emulator.storage.path)
+ }
+ context.startActivity(intent)
+ }
+ suspend fun launch(emulator: Emulator, rom: Rom) {
+ when (emulator.type) {
+ Emulator.TYPE_PPSSPP -> {
+ ppsspp(emulator, rom.path)
+ }
+
+ //Emulator.TYPE_N64 -> {}
+
+ else -> {
+ val coreFile = emulator.coreFiles.firstOrNull() ?: return context.toast(R.string.no_core)
+ val extractToPath = File(ContextCompat.getDataDir(context), "cores")
+ val coreName =
+ if (coreFile.extension == "zip") coreFile.nameWithoutExtension else coreFile.name
+ val internalCorePath = File(extractToPath, coreName)
+ if (coreFile.exists() && !internalCorePath.exists()) {
+ extractToPath.mkdirs()
+ if (coreFile.extension == "zip") {
+ coreFile.unzip(extractToPath = extractToPath)
+ } else {
+ coreFile.copyTo(internalCorePath)
+ }
+ }
+ val intent = Intent(context, RetroActivityFuture::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ putExtra("ROM", rom.path.path)
+ putExtra("LIBRETRO", internalCorePath.path)
+ putExtra("CONFIGFILE", emulator.storage.retroarchConfigFile.path)
+ putExtra(
+ "IME", Settings.Secure.getString(
+ context.contentResolver,
+ Settings.Secure.DEFAULT_INPUT_METHOD
+ )
+ )
+ putExtra("DATADIR", context.applicationInfo.dataDir)
+ putExtra("APK", context.applicationInfo.sourceDir)
+ //putExtra("SDCARD", Environment.getExternalStorageDirectory().absolutePath)
+ putExtra("SDCARD", emulator.storage.path)
+ val external =
+ Environment.getExternalStorageDirectory().absolutePath + "/Android/data/" + context.packageName + "/files"
+ putExtra("EXTERNAL", external)
+ }
+ context.startActivity(intent)
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/core/src/main/java/moe/tabidachi/emulator/common/Json.kt b/core/src/main/java/moe/tabidachi/emulator/common/Json.kt
index 8183eba..a42693d 100644
--- a/core/src/main/java/moe/tabidachi/emulator/common/Json.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/common/Json.kt
@@ -7,7 +7,7 @@ fun SharedJson() = Json {
isLenient = true
allowSpecialFloatingPointValues = true
allowStructuredMapKeys = true
- prettyPrint = false
+ prettyPrint = true
useArrayPolymorphism = false
ignoreUnknownKeys = true
}
\ No newline at end of file
diff --git a/core/src/main/java/moe/tabidachi/emulator/common/MenuToggle.kt b/core/src/main/java/moe/tabidachi/emulator/common/MenuToggle.kt
new file mode 100644
index 0000000..66fd4ae
--- /dev/null
+++ b/core/src/main/java/moe/tabidachi/emulator/common/MenuToggle.kt
@@ -0,0 +1,31 @@
+package moe.tabidachi.emulator.common
+
+/**
+ * # 0: None
+ * # 1: Down + Y + L1 + R1
+ * # 2: L3 + R3
+ * # 3: L1 + R1 + Start + Select
+ * # 4: Start + Select
+ * # 5: L3 + R1
+ * # 6: L1 + R1
+ * # 7: Hold Start (2 seconds)
+ * # 8: Hold Select (2 seconds)
+ * # 9: Down + Select
+ * # 10: L2 + R2
+ */
+enum class MenuToggle(
+ val value: Int,
+ val text: String
+) {
+ None(0, "None"),
+ DownYL1R1(1, "Down + Y + L1 + R1"),
+ L3R3(2, "L3 + R3"),
+ L1R1StartSelect(3, "L1 + R1 + Start + Select"),
+ StartSelect(4, "Start + Select"),
+ L3R1(5, "L3 + R1"),
+ L1R1(6, "L1 + R1"),
+ HoldStart(7, "Hold Start (2 seconds)"),
+ HoldSelect(8, "Hold Select (2 seconds)"),
+ DownSelect(9, "Down + Select"),
+ L2R2(10, "L2 + R2"),
+}
\ No newline at end of file
diff --git a/core/src/main/java/moe/tabidachi/emulator/common/ktx/Context.kt b/core/src/main/java/moe/tabidachi/emulator/common/ktx/Context.kt
new file mode 100644
index 0000000..291a152
--- /dev/null
+++ b/core/src/main/java/moe/tabidachi/emulator/common/ktx/Context.kt
@@ -0,0 +1,13 @@
+package moe.tabidachi.emulator.common.ktx
+
+import android.content.Context
+import android.widget.Toast
+import androidx.annotation.StringRes
+
+fun Context.toast(text: String, duration: Int = Toast.LENGTH_SHORT) {
+ Toast.makeText(this, text, duration).show()
+}
+
+fun Context.toast(@StringRes text: Int, duration: Int = Toast.LENGTH_SHORT) {
+ Toast.makeText(this, text, duration).show()
+}
\ No newline at end of file
diff --git a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorConfig.kt b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorConfig.kt
index 63d7ea0..cd03b65 100644
--- a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorConfig.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorConfig.kt
@@ -1,30 +1,85 @@
package moe.tabidachi.emulator.data
+import androidx.annotation.IntDef
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.encodeToStream
+import java.io.File
-/**
- * @param extensions 游戏扩展名
- * @param iconPath 模拟器图标的相对路径
- */
-@Serializable
-data class EmulatorConfig(
- @SerialName("title")
- val title: String,
- @SerialName("extensions")
- val extensions: List = emptyList(),
- @SerialName("icon_path")
- val iconPath: String? = null,
- @SerialName("background_path")
- val backgroundPath: String? = null,
- @SerialName("roms_paths")
- val romsPaths: List = emptyList(),
- @SerialName("images_paths")
- val imagesPaths: List = emptyList(),
- @SerialName("roms_scan_mode")
- val romsScanMode: Int = 0,
- @SerialName("roms_size")
- val romsSize: Int = 0,
- @SerialName("roms")
- val roms: List = emptyList()
-)
+class Emulator(
+ val id: String,
+ val path: String,
+ val storage: Storage,
+ var config: Config,
+ val json: Json,
+) {
+ internal val root = storage.path
+ val iconFile: File = File(root, config.iconPath ?: "")
+ val title: String = config.title
+ val backgroundFile: File = File(root, config.backgroundPath ?: "")
+ val romsSize: Int = if (config.romsSize <= 0) config.roms.size else config.roms.size
+ val roms = config.roms.map { Rom(this, it) }
+ val coreFiles get() = config.corePaths.map { File(root, it) }
+ @EmulatorType
+ val type = config.type
+ val actionIcons: List = config.actionIcons.map { ActionIcon(root, it) }
+
+ @OptIn(ExperimentalSerializationApi::class)
+ suspend fun setCore(core: String) {
+ val cores = config.corePaths.toMutableList()
+ if (cores.contains(core)) {
+ cores.remove(core)
+ cores.add(0, core)
+ val config = config.copy(corePaths = cores).also { config = it }
+ withContext(Dispatchers.IO) {
+ json.encodeToStream(config, File(path).outputStream())
+ }
+ }
+ }
+
+ /**
+ * @param extensions 游戏扩展名
+ * @param iconPath 模拟器图标的相对路径
+ */
+ @Serializable
+ data class Config(
+ @SerialName("title")
+ val title: String,
+ @SerialName("extensions")
+ val extensions: List = emptyList(),
+ @SerialName("icon_path")
+ val iconPath: String? = null,
+ @SerialName("background_path")
+ val backgroundPath: String? = null,
+ @SerialName("roms_paths")
+ val romsPaths: List = emptyList(),
+ @SerialName("images_paths")
+ val imagesPaths: List = emptyList(),
+ @SerialName("roms_scan_mode")
+ val romsScanMode: Int = 0,
+ @SerialName("core_paths")
+ val corePaths: List = emptyList(),
+ @SerialName("emulator_type")
+ @EmulatorType
+ val type: Int,
+ @SerialName("action_icons")
+ val actionIcons: List = emptyList(),
+ @SerialName("roms_size")
+ val romsSize: Int = 0,
+ @SerialName("roms")
+ val roms: List = emptyList(),
+ )
+
+ @IntDef(value = [TYPE_RETROARCH, TYPE_PPSSPP, TYPE_N64])
+ annotation class EmulatorType
+
+ companion object {
+ const val TYPE_RETROARCH = 0
+ const val TYPE_PPSSPP = 1
+ const val TYPE_N64 = 2
+ }
+}
diff --git a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorDataSource.kt b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorDataSource.kt
index 447ad25..edc6ee9 100644
--- a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorDataSource.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorDataSource.kt
@@ -3,9 +3,6 @@ package moe.tabidachi.emulator.data
import android.util.Log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.serialization.json.Json
@@ -15,10 +12,11 @@ import java.io.File
import javax.inject.Inject
interface EmulatorDataSource : DataSource {
- val storageConfigList: Flow>>
- fun getAssetsFile(): Flow
- fun getEmulatorList(): Flow>
- fun getRomListByEmulatorId(id: String): Flow>
+ val storageList: Flow>
+ val emulatorList: Flow>
+ val assetsFile: Flow
+ //fun getEmulatorListItem(): Flow>
+ //fun getRomListByEmulatorId(id: Any): Flow>
}
class SdcardEmulatorDataSource @Inject constructor(
@@ -26,76 +24,44 @@ class SdcardEmulatorDataSource @Inject constructor(
private val sdcardPaths: MutableStateFlow>,
private val json: Json
) : EmulatorDataSource {
- override val storageConfigList: Flow>> =
- sdcardPaths/*.distinctUntilChanged { old, new ->
- old.containsAll(new)
- }*/.map { paths: Set ->
+ override val storageList: Flow> =
+ sdcardPaths.map { paths: Set ->
paths.map { root: String ->
root to File(root, "config.json")
}.mapNotNull { (root, file) ->
runCatching {
- root to json.decodeFromString(file.readText())
+ Storage(root, json.decodeFromString(file.readText()))
}.onFailure {
Log.e(TAG, it.message, it)
}.getOrNull()
}
}
- override fun getAssetsFile(): Flow {
- return storageConfigList.mapNotNull {
- it.map { (root, storageConfig) ->
- File(root, storageConfig.assetsPath)
- }.firstOrNull {
+ override val emulatorList: Flow> = storageList.map { configs ->
+ configs.map { storage ->
+ storage.emulatorConfigFiles.filter {
it.exists()
+ }.mapNotNull { file ->
+ runCatching {
+ Emulator(
+ id = file.toString(),
+ path = file.path,
+ storage = storage,
+ config = json.decodeFromString(file.readText()),
+ json = json,
+ )
+ }.onFailure {
+ Log.e(TAG, "$file 反序列化失败", it)
+ }.getOrNull()
}
- }
+ }.flatten()
}
- private fun getEmulatorConfigList(): Flow>> {
- return storageConfigList.map { configs ->
- configs.map { (root, config) ->
- config.emulatorConfigPaths.map { path: String ->
- File(root, path)
- }.filter {
- val exists = it.exists()
- if (!exists) Log.d(TAG, "$it 文件不存在")
- exists
- }.mapNotNull { file ->
- runCatching {
- root to json.decodeFromString(file.readText())
- }.onFailure {
- Log.e(TAG, "$file 反序列化失败", it)
- }.getOrNull()
- }
- }.flatten()
- }
- }
-
- override fun getEmulatorList(): Flow> {
- return getEmulatorConfigList().map {
- it.map { (root, config) ->
- EmulatorListItem(
- id = root,
- icon = File(root, config.iconPath ?: ""),
- title = config.title,
- background = File(root, config.backgroundPath ?: ""),
- romsSize = config.roms.size,
- )
- }
- }
- }
-
- override fun getRomListByEmulatorId(id: String): Flow> {
- return getEmulatorConfigList().map {
- it.first { it.first == id }
- }.map { (root, config) ->
- config.roms.map {
- RomListItem(
- name = it.name ?: "",
- path = File(root, it.path),
- imagePath = File(root, it.imagePath ?: "")
- )
- }
+ override val assetsFile: Flow = storageList.mapNotNull {
+ it.map { storage ->
+ storage.assetsFile
+ }.firstOrNull {
+ it.exists()
}
}
}
diff --git a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorListItem.kt b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorListItem.kt
index 300c823..9f94c32 100644
--- a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorListItem.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorListItem.kt
@@ -4,8 +4,18 @@ import java.io.File
data class EmulatorListItem(
val id: String,
- val icon: File,
+ val icon: File?,
val title: String,
- val background: File,
+ val background: File?,
val romsSize: Int,
-)
\ No newline at end of file
+) {
+ companion object {
+ val Empty = EmulatorListItem(
+ id = "",
+ icon = null,
+ title = "",
+ background = null,
+ romsSize = 0
+ )
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorRepository.kt b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorRepository.kt
index f08aa98..cdd918b 100644
--- a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorRepository.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorRepository.kt
@@ -1,22 +1,41 @@
package moe.tabidachi.emulator.data
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.stateIn
import java.io.File
import javax.inject.Inject
interface EmulatorRepository : Repository {
- fun getAssetsFile(): Flow
- fun getEmulatorList(): Flow>
+ val storageList: Flow>
+ val assetsFile: Flow
+ val emulatorList: Flow>
}
class DefaultEmulatorRepository @Inject constructor(
- private val emulatorDataSource: EmulatorDataSource
+ private val emulatorDataSource: EmulatorDataSource,
+ private val scope: CoroutineScope
) : EmulatorRepository {
- override fun getAssetsFile(): Flow {
- return emulatorDataSource.getAssetsFile()
- }
+ override val storageList: Flow> = emulatorDataSource.storageList
+ .stateIn(
+ scope,
+ started = SharingStarted.Lazily,
+ initialValue = emptyList()
+ )
- override fun getEmulatorList(): Flow> {
- return emulatorDataSource.getEmulatorList()
- }
+ override val assetsFile: Flow = emulatorDataSource.assetsFile
+ .shareIn(
+ scope,
+ started = SharingStarted.WhileSubscribed(5000),
+ replay = 1
+ )
+
+ override val emulatorList: Flow> = emulatorDataSource.emulatorList
+ .stateIn(
+ scope,
+ started = SharingStarted.Lazily,
+ initialValue = emptyList()
+ )
}
\ No newline at end of file
diff --git a/core/src/main/java/moe/tabidachi/emulator/data/Rom.kt b/core/src/main/java/moe/tabidachi/emulator/data/Rom.kt
index f151097..946daae 100644
--- a/core/src/main/java/moe/tabidachi/emulator/data/Rom.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/data/Rom.kt
@@ -2,13 +2,24 @@ package moe.tabidachi.emulator.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import java.io.File
-@Serializable
-data class Rom(
- @SerialName("name")
- val name: String? = null,
- @SerialName("path")
- val path: String,
- @SerialName("image_path")
- val imagePath: String? = null
-)
+class Rom(
+ emulator: Emulator,
+ config: Config
+) {
+ val name: String = config.name ?: ""
+ val id: String = emulator.id + name
+ val path = File(emulator.root, config.path)
+ val imageFile = File(emulator.root, config.imagePath ?: "")
+
+ @Serializable
+ data class Config(
+ @SerialName("name")
+ val name: String? = null,
+ @SerialName("path")
+ val path: String,
+ @SerialName("image_path")
+ val imagePath: String? = null,
+ )
+}
diff --git a/core/src/main/java/moe/tabidachi/emulator/data/RomListItem.kt b/core/src/main/java/moe/tabidachi/emulator/data/RomListItem.kt
index eae6fed..342b84b 100644
--- a/core/src/main/java/moe/tabidachi/emulator/data/RomListItem.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/data/RomListItem.kt
@@ -3,7 +3,10 @@ package moe.tabidachi.emulator.data
import java.io.File
data class RomListItem(
+ val id: String,
val name: String,
val path: File,
val imagePath: File
-)
+) {
+ constructor(rom: Rom) : this(rom.id, rom.name, rom.path, rom.imageFile)
+}
diff --git a/core/src/main/java/moe/tabidachi/emulator/data/StorageConfig.kt b/core/src/main/java/moe/tabidachi/emulator/data/StorageConfig.kt
index 6c726d6..c149c4f 100644
--- a/core/src/main/java/moe/tabidachi/emulator/data/StorageConfig.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/data/StorageConfig.kt
@@ -2,17 +2,62 @@ package moe.tabidachi.emulator.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import java.io.File
-@Serializable
-data class StorageConfig(
- @SerialName("emulator_config_paths")
- val emulatorConfigPaths: List,
- @SerialName("assets_path")
- val assetsPath: String,
- @SerialName("retroarch_config_path")
- val retroarchConfigPath: String,
- @SerialName("bios_path")
- val biosPath: String,
- @SerialName("cores_path")
- val coresPath: String
-)
\ No newline at end of file
+class Storage(
+ val path: String,
+ private val config: Config
+) {
+ val assetsFile: File = File(path, config.assetsPath)
+ val emulatorConfigFiles: List = config.emulatorConfigPaths.map { File(path, it) }
+ val retroarchConfigFile: File = File(path, config.retroarchConfigFile)
+ val actionIcons: List = config.actionIcons.map { ActionIcon(path, it) }
+
+ @Serializable
+ data class Config(
+ @SerialName("emulator_config_paths")
+ val emulatorConfigPaths: List,
+ @SerialName("assets_path")
+ val assetsPath: String,
+ @SerialName("retroarch_config_path")
+ val retroarchConfigPath: String,
+ @SerialName("retroarch_config_file")
+ val retroarchConfigFile: String,
+ @SerialName("bios_path")
+ val biosPath: String,
+ @SerialName("cores_path")
+ val coresPath: String,
+ @SerialName("action_icons")
+ val actionIcons: List = emptyList()
+ )
+}
+
+class ActionIcon(
+ root: String,
+ config: Config
+) {
+ val keycode = config.keycode
+ val label = config.label
+ val iconFile = File(root, config.iconPath)
+ val type = config.type
+
+ @Serializable
+ data class Config(
+ @SerialName("keycode")
+ val keycode: Int,
+ @SerialName("label")
+ val label: String,
+ @SerialName("icon_path")
+ val iconPath: String,
+ @SerialName("type")
+ val type: Type
+ )
+
+ enum class Type {
+ SETTINGS,
+ SEARCH,
+ FAVORITE,
+ OK,
+ BACK
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/moe/tabidachi/emulator/di/RepositoryModule.kt b/core/src/main/java/moe/tabidachi/emulator/di/RepositoryModule.kt
index eccd079..997ef52 100644
--- a/core/src/main/java/moe/tabidachi/emulator/di/RepositoryModule.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/di/RepositoryModule.kt
@@ -6,6 +6,9 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import moe.tabidachi.emulator.data.DefaultEmulatorRepository
import moe.tabidachi.emulator.data.DefaultPreferencesDataSource
import moe.tabidachi.emulator.data.EmulatorDataSource
@@ -23,6 +26,6 @@ object RepositoryModule {
context: Context,
emulatorDataSource: EmulatorDataSource
): EmulatorRepository {
- return DefaultEmulatorRepository(emulatorDataSource)
+ return DefaultEmulatorRepository(emulatorDataSource, CoroutineScope(Dispatchers.Default + SupervisorJob()))
}
}
\ No newline at end of file
diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsFileUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsFileUseCase.kt
index 4fbb59a..452bdc8 100644
--- a/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsFileUseCase.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsFileUseCase.kt
@@ -9,6 +9,6 @@ class GetAssetsFileUseCase @Inject constructor(
private val emulatorRepository: EmulatorRepository
) {
operator fun invoke(): Flow {
- return emulatorRepository.getAssetsFile()
+ return emulatorRepository.assetsFile
}
}
\ No newline at end of file
diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/GetEmulatorListUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/GetEmulatorListUseCase.kt
index ba930b4..847c18b 100644
--- a/core/src/main/java/moe/tabidachi/emulator/domain/GetEmulatorListUseCase.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/domain/GetEmulatorListUseCase.kt
@@ -1,6 +1,7 @@
package moe.tabidachi.emulator.domain
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
import moe.tabidachi.emulator.data.EmulatorListItem
import moe.tabidachi.emulator.data.EmulatorRepository
import javax.inject.Inject
@@ -8,7 +9,15 @@ import javax.inject.Inject
class GetEmulatorListUseCase @Inject constructor(
private val emulatorRepository: EmulatorRepository
) {
- operator fun invoke(): Flow> {
- return emulatorRepository.getEmulatorList()
+ operator fun invoke(): Flow> = emulatorRepository.emulatorList.map {
+ it.map { emulator ->
+ EmulatorListItem(
+ id = emulator.id,
+ icon = emulator.iconFile,
+ title = emulator.title,
+ background = emulator.backgroundFile,
+ romsSize = emulator.romsSize,
+ )
+ }
}
}
\ No newline at end of file
diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/GetRomListUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/GetRomListUseCase.kt
new file mode 100644
index 0000000..1fc270f
--- /dev/null
+++ b/core/src/main/java/moe/tabidachi/emulator/domain/GetRomListUseCase.kt
@@ -0,0 +1,23 @@
+package moe.tabidachi.emulator.domain
+
+import kotlinx.coroutines.flow.map
+import moe.tabidachi.emulator.data.EmulatorRepository
+import moe.tabidachi.emulator.data.RomListItem
+import javax.inject.Inject
+
+class GetRomListUseCase @Inject constructor(
+ private val emulatorRepository: EmulatorRepository
+) {
+ operator fun invoke(emulatorId: String) = emulatorRepository.emulatorList.map {
+ it.first { it.id == emulatorId }
+ }.map { emulator ->
+ emulator.roms.map {
+ RomListItem(
+ id = it.id,
+ name = it.name,
+ path = it.path,
+ imagePath = it.imageFile
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/GetSdcardPathsUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/GetSdcardPathsUseCase.kt
index ab99cb3..d3b1a03 100644
--- a/core/src/main/java/moe/tabidachi/emulator/domain/GetSdcardPathsUseCase.kt
+++ b/core/src/main/java/moe/tabidachi/emulator/domain/GetSdcardPathsUseCase.kt
@@ -6,6 +6,7 @@ import android.os.storage.StorageVolume
import android.util.Log
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import moe.tabidachi.emulator.common.ktx.TAG
import moe.tabidachi.emulator.di.SdcardPaths
@@ -17,9 +18,9 @@ class GetSdcardPathsUseCase @Inject constructor(
@SdcardPaths
private val sdcardPaths: MutableStateFlow>
) {
- operator fun invoke(): List {
- return kotlin.runCatching {
- val storageManager = context.getSystemService() ?: return emptyList()
+ suspend operator fun invoke(): List = coroutineScope {
+ kotlin.runCatching {
+ val storageManager = context.getSystemService() ?: return@coroutineScope emptyList()
val clazz = Class.forName("android.os.storage.StorageVolume")
val getVolumeListMethod = storageManager.javaClass.getMethod("getVolumeList")
val getPathMethod = clazz.getMethod("getPath")
diff --git a/core/src/main/res/values-zh-rCN/strings.xml b/core/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000..3107c14
--- /dev/null
+++ b/core/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,4 @@
+
+
+ 没有核心库
+
\ No newline at end of file
diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml
new file mode 100644
index 0000000..79232c6
--- /dev/null
+++ b/core/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ No Core
+
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index fd5fd87..a695ced 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,24 +1,24 @@
[versions]
-agp = "8.8.0"
+agp = "8.9.0"
kotlin = "2.1.10"
coreKtx = "1.15.0"
appcompat = "1.7.0"
-composeBom = "2025.01.01"
+composeBom = "2025.03.00"
tvMaterial = "1.1.0-alpha01"
lifecycleRuntimeKtx = "2.8.7"
-activityCompose = "1.10.0"
+activityCompose = "1.10.1"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
material = "1.12.0"
-ktor = "3.0.3"
+ktor = "3.1.1"
hilt = "2.55"
-ksp = "2.1.10-1.0.29"
+ksp = "2.1.10-1.0.31"
hilt-navigation-compose = "1.2.0"
-compose-navigation = "2.8.6"
+compose-navigation = "2.8.9"
kotlin-serialization = "1.8.0"
-datastore = "1.1.2"
-coil3 = "3.0.4"
+datastore = "1.1.3"
+coil3 = "3.1.0"
work = "2.10.0"
androidx-room = "2.6.1"
[libraries]
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index e18bc25..37f853b 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
index f5feea6..f3b75f3 100755
--- a/gradlew
+++ b/gradlew
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
-' "$PWD" ) || exit
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
diff --git a/libppsspp/.gitignore b/libppsspp/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/libppsspp/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/libppsspp/build.gradle.kts b/libppsspp/build.gradle.kts
new file mode 100644
index 0000000..e3c41d9
--- /dev/null
+++ b/libppsspp/build.gradle.kts
@@ -0,0 +1,88 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+}
+
+val projectPath = File(project.rootDir, "submodules/ppsspp")
+
+android {
+ namespace = "org.ppsspp.ppsspp"
+ compileSdk = 35
+ ndkVersion = "21.4.7075529"
+
+ defaultConfig {
+ minSdk = 23
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ externalNativeBuild {
+ ndkBuild {
+ arguments(
+ "-DANDROID=true",
+ "-DANDROID_PLATFORM=android-16",
+ "-DANDROID_TOOLCHAIN=clang",
+ "-DANDROID_CPP_FEATURES=",
+ "-DANDROID_STL=c++_static",
+ "-j${Runtime.getRuntime().availableProcessors()}",
+ )
+ }
+ }
+
+ buildConfigField("String", "FLAVOR", "\"normal\"")
+ }
+ sourceSets {
+ getByName("main") {
+ //manifest.srcFile(File(projectPath, "android/AndroidManifest.xml"))
+ java.srcDirs(
+ File(projectPath, "android/src"),
+ )
+ res.srcDirs(
+ File(projectPath, "android/res"),
+ File(projectPath, "android/normal/res"),
+ )
+ assets.srcDirs(
+ File(projectPath, "assets"),
+ )
+ }
+ }
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ buildFeatures {
+ buildConfig = true
+ }
+ externalNativeBuild {
+ cmake {
+ path(File(projectPath, "CMakeLists.txt"))
+ version = "3.22.1"
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ lint {
+ baseline = file("lint-baseline.xml")
+ }
+}
+
+dependencies {
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ implementation(files(File(projectPath, "android/libs/com.bda.controller.jar")))
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+}
\ No newline at end of file
diff --git a/libppsspp/consumer-rules.pro b/libppsspp/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/libppsspp/lint-baseline.xml b/libppsspp/lint-baseline.xml
new file mode 100644
index 0000000..97a1e12
--- /dev/null
+++ b/libppsspp/lint-baseline.xml
@@ -0,0 +1,1536 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libppsspp/proguard-rules.pro b/libppsspp/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/libppsspp/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/libppsspp/src/main/AndroidManifest.xml b/libppsspp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..fbdb235
--- /dev/null
+++ b/libppsspp/src/main/AndroidManifest.xml
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libretroarch/.gitignore b/libretroarch/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/libretroarch/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/libretroarch/build.gradle.kts b/libretroarch/build.gradle.kts
new file mode 100644
index 0000000..3fbcb86
--- /dev/null
+++ b/libretroarch/build.gradle.kts
@@ -0,0 +1,75 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+}
+
+android {
+ namespace = "com.retroarch"
+ compileSdk = 35
+ ndkVersion = "22.0.7026061"
+
+ defaultConfig {
+ minSdk = 23
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ externalNativeBuild {
+ ndkBuild {
+ arguments += "-j${Runtime.getRuntime().availableProcessors()}"
+ }
+ }
+
+ buildConfigField("boolean", "PLAY_STORE_BUILD", "false")
+ resValue("string", "app_name", "RetroArch")
+ }
+ val path = File(project.rootDir, "submodules/RetroArch")
+ sourceSets {
+ getByName("main") {
+ //manifest.srcFile(File(path, "pkg/android/phoenix/AndroidManifest.xml"))
+ java.srcDirs(
+ File(path, "pkg/android/phoenix/src"),
+ File(path, "pkg/android/phoenix-common/src"),
+ File(path, "pkg/android/play-core-stub"),
+ )
+ res.srcDirs(
+ File(path, "pkg/android/phoenix/res"),
+ File(path, "pkg/android/phoenix-common/res"),
+ )
+ }
+ }
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ buildFeatures {
+ buildConfig = true
+ }
+ externalNativeBuild {
+ ndkBuild {
+ path(File(path, "pkg/android/phoenix-common/jni/Android.mk"))
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+}
+
+dependencies {
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+}
\ No newline at end of file
diff --git a/libretroarch/consumer-rules.pro b/libretroarch/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/libretroarch/proguard-rules.pro b/libretroarch/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/libretroarch/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/libretroarch/src/main/AndroidManifest.xml b/libretroarch/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..23bdd9c
--- /dev/null
+++ b/libretroarch/src/main/AndroidManifest.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/settings.gradle.kts b/settings.gradle.kts
index d9a6380..61e6722 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -23,3 +23,5 @@ rootProject.name = "Emulator"
include(":app-default")
include(":core")
include(":ui")
+include(":libretroarch")
+include(":libppsspp")
diff --git a/submodules/RetroArch b/submodules/RetroArch
new file mode 160000
index 0000000..a3f1abf
--- /dev/null
+++ b/submodules/RetroArch
@@ -0,0 +1 @@
+Subproject commit a3f1abfad7afea0160364562b8c1934b45752ce5
diff --git a/submodules/ppsspp b/submodules/ppsspp
new file mode 160000
index 0000000..6423cc9
--- /dev/null
+++ b/submodules/ppsspp
@@ -0,0 +1 @@
+Subproject commit 6423cc9ea50bb5b797ba118f9637902bb272d9bf
diff --git a/ui/src/main/java/moe/tabidachi/emulator/ui/components/TvDialog.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/components/TvDialog.kt
index ed77fd3..5daeb18 100644
--- a/ui/src/main/java/moe/tabidachi/emulator/ui/components/TvDialog.kt
+++ b/ui/src/main/java/moe/tabidachi/emulator/ui/components/TvDialog.kt
@@ -25,6 +25,9 @@ import moe.tabidachi.emulator.ui.common.TvPreview
@Composable
fun TvDialog(
onDismissRequest: () -> Unit,
+ parent: BoxScope.(Modifier) -> Modifier = {
+ it.align(Alignment.BottomCenter)
+ },
content: @Composable BoxScope.() -> Unit
) {
Dialog(
@@ -36,9 +39,7 @@ fun TvDialog(
) {
Surface(
shape = TvDialogShape,
- modifier = Modifier
- .align(Alignment.BottomCenter)
- .padding(16.dp),
+ modifier = parent(Modifier).padding(16.dp),
content = content
)
}
diff --git a/ui/src/main/res/values-zh-rCN/strings.xml b/ui/src/main/res/values-zh-rCN/strings.xml
index 01c892b..dd1e174 100644
--- a/ui/src/main/res/values-zh-rCN/strings.xml
+++ b/ui/src/main/res/values-zh-rCN/strings.xml
@@ -10,4 +10,10 @@
确定
取消
%s GAMES
+ 菜单切换
+ 核心选择
+ 搜索
+ 退出
+ 您确定要退出吗?
+ 设置
\ No newline at end of file
diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml
index 5ec279a..2a087df 100644
--- a/ui/src/main/res/values/strings.xml
+++ b/ui/src/main/res/values/strings.xml
@@ -10,4 +10,10 @@
Confirm
Cancel
%s GAMES
+ Menu Toggle
+ Core Select
+ Search
+ Exit
+ Are you sure to exit?
+ Settings
\ No newline at end of file