v1.1
6
.gitmodules
vendored
Normal file
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
26
app-default/lint-baseline.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.9.0" type="baseline" client="gradle" dependencies="false" name="AGP (8.9.0)" variant="all" version="8.9.0">
|
||||
|
||||
<issue
|
||||
id="LeanbackUsesWifi"
|
||||
message="Requiring Wifi permissions limits app availability on TVs that support only Ethernet"
|
||||
errorLine1=" <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="7"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ExpiredTargetSdkVersion"
|
||||
message="Google Play requires that apps target API level 33 or higher."
|
||||
errorLine1=" targetSdk = 28"
|
||||
errorLine2=" ~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle.kts"
|
||||
line="20"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
</issues>
|
||||
6
app-default/proguard-rules.pro
vendored
@@ -18,4 +18,8 @@
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-renamesourcefileattribute SourceFile
|
||||
# ppsspp
|
||||
-keep class org.ppsspp.ppsspp.** { *; }
|
||||
# RetroArch
|
||||
-keep class com.retroarch.** { *; }
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
@@ -17,9 +18,11 @@
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/ic_launcher"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:isGame="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Emulator">
|
||||
android:theme="@style/Theme.Emulator"
|
||||
tools:replace="android:banner,android:name">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
@@ -28,8 +31,19 @@
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<category android:name="tv.ouya.intent.category.GAME" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
BIN
app-default/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
@@ -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
|
||||
|
||||
/**
|
||||
* <a href="https://m3.material.io/components/progress-indicators/overview" class="external"
|
||||
* target="_blank">Indeterminate Material Design linear progress indicator</a>.
|
||||
*
|
||||
* 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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>()
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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<Char> = 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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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<MenuToggleItem>,
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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<Pair<Emulator, Rom>>,
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
@@ -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<HomeRoute> {
|
||||
@@ -21,11 +20,13 @@ fun NavGraphBuilder.home(navController: NavHostController) = composable<HomeRout
|
||||
viewModel.actions.copy(
|
||||
onActivityFinished = {
|
||||
(context as? Activity)?.finish()
|
||||
}, navigateToRoms = navController::navigateToRoms
|
||||
}, navigateToRoms = navController::navigateToRoms,
|
||||
onExit = {
|
||||
(context as? Activity)?.finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
HomeScreen(state = state, actions = actions)
|
||||
MediaChangeListener(onMediaChange = actions.onMediaChange)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package moe.tabidachi.emulator.ui.home
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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
|
||||
@@ -9,8 +12,13 @@ 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.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -23,26 +31,42 @@ 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.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.wear.compose.material.TimeText
|
||||
import coil3.compose.AsyncImage
|
||||
import moe.tabidachi.emulator.R
|
||||
import moe.tabidachi.emulator.common.AspectRatio
|
||||
import moe.tabidachi.emulator.common.Permissions
|
||||
import moe.tabidachi.emulator.data.ActionIcon
|
||||
import moe.tabidachi.emulator.data.EmulatorListItem
|
||||
import moe.tabidachi.emulator.ui.common.ActionIcon
|
||||
import moe.tabidachi.emulator.ui.common.AssetsExtractDialog
|
||||
import moe.tabidachi.emulator.ui.common.EthernetStatusIcon
|
||||
import moe.tabidachi.emulator.ui.common.ExitDialog
|
||||
import moe.tabidachi.emulator.ui.common.GamepadIndicatorBar
|
||||
import moe.tabidachi.emulator.ui.common.MenuToggleDialog
|
||||
import moe.tabidachi.emulator.ui.common.NoSdcardDialog
|
||||
import moe.tabidachi.emulator.ui.common.PermissionDialog
|
||||
import moe.tabidachi.emulator.ui.common.PreviewSurface
|
||||
import moe.tabidachi.emulator.ui.common.SearchDialog
|
||||
import moe.tabidachi.emulator.ui.common.SettingsActions
|
||||
import moe.tabidachi.emulator.ui.common.SettingsDialog
|
||||
import moe.tabidachi.emulator.ui.common.TvPreview
|
||||
import moe.tabidachi.emulator.ui.common.WifiStatusIcon
|
||||
import moe.tabidachi.emulator.ui.common.rememberPivotBringIntoViewSpec
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
|
||||
@@ -63,8 +87,35 @@ fun HomeScreen(
|
||||
)
|
||||
val size = state.emulatorList.size
|
||||
var focusedItem by remember { mutableStateOf<EmulatorListItem?>(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()
|
||||
)
|
||||
|
||||
@@ -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<AssetsState>,
|
||||
@PermissionStateFlow
|
||||
private val permissionState: MutableStateFlow<PermissionState>,
|
||||
@SdcardPaths
|
||||
private val sdcardPaths: MutableStateFlow<Set<String>>,
|
||||
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<EmulatorListItem> ->
|
||||
_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<EmulatorListItem> ->
|
||||
_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<EmulatorListItem> = emptyList(),
|
||||
val isSdcardEmpty: Boolean = false
|
||||
val isSdcardEmpty: Boolean = false,
|
||||
val menuToggleItems: List<MenuToggleItem> = emptyList(),
|
||||
val actionIcons: List<ActionIcon> = emptyList(),
|
||||
val query: String = "",
|
||||
val queryRoms: List<Pair<Emulator, Rom>> = 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 = {}
|
||||
)
|
||||
|
||||
@@ -12,14 +12,12 @@ import kotlinx.serialization.Serializable
|
||||
fun NavGraphBuilder.roms(navController: NavHostController) = composable<RomsRoute> {
|
||||
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))
|
||||
|
||||
@@ -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<RomListItem?>(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
|
||||
|
||||
@@ -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<Set<String>>,
|
||||
private val getSdcardPathsUseCase: GetSdcardPathsUseCase,
|
||||
private val gameLauncher: GameLauncher
|
||||
) : ViewModel() {
|
||||
private val romId = savedStateHandle.toRoute<RomsRoute>().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<RomsRoute>().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<RomListItem> = emptyList(),
|
||||
val background: File? = null,
|
||||
val emulator: Emulator? = null,
|
||||
val actionIcons: List<ActionIcon> = 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<List<RomListItem>> {
|
||||
return emulatorDataSource.getRomListByEmulatorId(romId)
|
||||
}
|
||||
}
|
||||
10
app-default/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#0B152A"
|
||||
android:pathData="M0,0 h24 v24 h-24 z" />
|
||||
</vector>
|
||||
16
app-default/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:tint="#5E9AFF"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group
|
||||
android:scaleX="0.464"
|
||||
android:scaleY="0.464"
|
||||
android:translateX="6.432"
|
||||
android:translateY="6.432">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M21,6L3,6c-1.1,0 -2,0.9 -2,2v8c0,1.1 0.9,2 2,2h18c1.1,0 2,-0.9 2,-2L23,8c0,-1.1 -0.9,-2 -2,-2zM10,13L8,13v2c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1v-2L4,13c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h2L6,9c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v2h2c0.55,0 1,0.45 1,1s-0.45,1 -1,1zM15.5,15c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM19.5,12c-0.83,0 -1.5,-0.67 -1.5,-1.5S18.67,9 19.5,9s1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 752 B |
BIN
app-default/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 628 B |
BIN
app-default/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1012 B |
BIN
app-default/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.5 KiB |
BIN
app-default/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.1 KiB |
BIN
app-default/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
6
app-default/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<root-path
|
||||
name="storage"
|
||||
path="/storage/"/>
|
||||
</paths>
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package moe.tabidachi.emulator.common
|
||||
|
||||
data class AspectRatio(val value: Float) {
|
||||
companion object {
|
||||
val AR1_1 = AspectRatio(1f / 1f)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ fun SharedJson() = Json {
|
||||
isLenient = true
|
||||
allowSpecialFloatingPointValues = true
|
||||
allowStructuredMapKeys = true
|
||||
prettyPrint = false
|
||||
prettyPrint = true
|
||||
useArrayPolymorphism = false
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<String> = emptyList(),
|
||||
@SerialName("icon_path")
|
||||
val iconPath: String? = null,
|
||||
@SerialName("background_path")
|
||||
val backgroundPath: String? = null,
|
||||
@SerialName("roms_paths")
|
||||
val romsPaths: List<String> = emptyList(),
|
||||
@SerialName("images_paths")
|
||||
val imagesPaths: List<String> = emptyList(),
|
||||
@SerialName("roms_scan_mode")
|
||||
val romsScanMode: Int = 0,
|
||||
@SerialName("roms_size")
|
||||
val romsSize: Int = 0,
|
||||
@SerialName("roms")
|
||||
val roms: List<Rom> = 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<ActionIcon> = 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<String> = emptyList(),
|
||||
@SerialName("icon_path")
|
||||
val iconPath: String? = null,
|
||||
@SerialName("background_path")
|
||||
val backgroundPath: String? = null,
|
||||
@SerialName("roms_paths")
|
||||
val romsPaths: List<String> = emptyList(),
|
||||
@SerialName("images_paths")
|
||||
val imagesPaths: List<String> = emptyList(),
|
||||
@SerialName("roms_scan_mode")
|
||||
val romsScanMode: Int = 0,
|
||||
@SerialName("core_paths")
|
||||
val corePaths: List<String> = emptyList(),
|
||||
@SerialName("emulator_type")
|
||||
@EmulatorType
|
||||
val type: Int,
|
||||
@SerialName("action_icons")
|
||||
val actionIcons: List<ActionIcon.Config> = emptyList(),
|
||||
@SerialName("roms_size")
|
||||
val romsSize: Int = 0,
|
||||
@SerialName("roms")
|
||||
val roms: List<Rom.Config> = 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<Pair<String, StorageConfig>>>
|
||||
fun getAssetsFile(): Flow<File>
|
||||
fun getEmulatorList(): Flow<List<EmulatorListItem>>
|
||||
fun getRomListByEmulatorId(id: String): Flow<List<RomListItem>>
|
||||
val storageList: Flow<List<Storage>>
|
||||
val emulatorList: Flow<List<Emulator>>
|
||||
val assetsFile: Flow<File>
|
||||
//fun getEmulatorListItem(): Flow<List<EmulatorListItem>>
|
||||
//fun getRomListByEmulatorId(id: Any): Flow<List<RomListItem>>
|
||||
}
|
||||
|
||||
class SdcardEmulatorDataSource @Inject constructor(
|
||||
@@ -26,76 +24,44 @@ class SdcardEmulatorDataSource @Inject constructor(
|
||||
private val sdcardPaths: MutableStateFlow<Set<String>>,
|
||||
private val json: Json
|
||||
) : EmulatorDataSource {
|
||||
override val storageConfigList: Flow<List<Pair<String, StorageConfig>>> =
|
||||
sdcardPaths/*.distinctUntilChanged { old, new ->
|
||||
old.containsAll(new)
|
||||
}*/.map { paths: Set<String> ->
|
||||
override val storageList: Flow<List<Storage>> =
|
||||
sdcardPaths.map { paths: Set<String> ->
|
||||
paths.map { root: String ->
|
||||
root to File(root, "config.json")
|
||||
}.mapNotNull { (root, file) ->
|
||||
runCatching {
|
||||
root to json.decodeFromString<StorageConfig>(file.readText())
|
||||
Storage(root, json.decodeFromString<Storage.Config>(file.readText()))
|
||||
}.onFailure {
|
||||
Log.e(TAG, it.message, it)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAssetsFile(): Flow<File> {
|
||||
return storageConfigList.mapNotNull {
|
||||
it.map { (root, storageConfig) ->
|
||||
File(root, storageConfig.assetsPath)
|
||||
}.firstOrNull {
|
||||
override val emulatorList: Flow<List<Emulator>> = 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<Emulator.Config>(file.readText()),
|
||||
json = json,
|
||||
)
|
||||
}.onFailure {
|
||||
Log.e(TAG, "$file 反序列化失败", it)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
private fun getEmulatorConfigList(): Flow<List<Pair<String, EmulatorConfig>>> {
|
||||
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<EmulatorConfig>(file.readText())
|
||||
}.onFailure {
|
||||
Log.e(TAG, "$file 反序列化失败", it)
|
||||
}.getOrNull()
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getEmulatorList(): Flow<List<EmulatorListItem>> {
|
||||
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<List<RomListItem>> {
|
||||
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<File> = storageList.mapNotNull {
|
||||
it.map { storage ->
|
||||
storage.assetsFile
|
||||
}.firstOrNull {
|
||||
it.exists()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
val Empty = EmulatorListItem(
|
||||
id = "",
|
||||
icon = null,
|
||||
title = "",
|
||||
background = null,
|
||||
romsSize = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<File>
|
||||
fun getEmulatorList(): Flow<List<EmulatorListItem>>
|
||||
val storageList: Flow<List<Storage>>
|
||||
val assetsFile: Flow<File>
|
||||
val emulatorList: Flow<List<Emulator>>
|
||||
}
|
||||
|
||||
class DefaultEmulatorRepository @Inject constructor(
|
||||
private val emulatorDataSource: EmulatorDataSource
|
||||
private val emulatorDataSource: EmulatorDataSource,
|
||||
private val scope: CoroutineScope
|
||||
) : EmulatorRepository {
|
||||
override fun getAssetsFile(): Flow<File> {
|
||||
return emulatorDataSource.getAssetsFile()
|
||||
}
|
||||
override val storageList: Flow<List<Storage>> = emulatorDataSource.storageList
|
||||
.stateIn(
|
||||
scope,
|
||||
started = SharingStarted.Lazily,
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
override fun getEmulatorList(): Flow<List<EmulatorListItem>> {
|
||||
return emulatorDataSource.getEmulatorList()
|
||||
}
|
||||
override val assetsFile: Flow<File> = emulatorDataSource.assetsFile
|
||||
.shareIn(
|
||||
scope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
replay = 1
|
||||
)
|
||||
|
||||
override val emulatorList: Flow<List<Emulator>> = emulatorDataSource.emulatorList
|
||||
.stateIn(
|
||||
scope,
|
||||
started = SharingStarted.Lazily,
|
||||
initialValue = emptyList()
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
@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
|
||||
)
|
||||
class Storage(
|
||||
val path: String,
|
||||
private val config: Config
|
||||
) {
|
||||
val assetsFile: File = File(path, config.assetsPath)
|
||||
val emulatorConfigFiles: List<File> = config.emulatorConfigPaths.map { File(path, it) }
|
||||
val retroarchConfigFile: File = File(path, config.retroarchConfigFile)
|
||||
val actionIcons: List<ActionIcon> = config.actionIcons.map { ActionIcon(path, it) }
|
||||
|
||||
@Serializable
|
||||
data class Config(
|
||||
@SerialName("emulator_config_paths")
|
||||
val emulatorConfigPaths: List<String>,
|
||||
@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<ActionIcon.Config> = 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
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,6 @@ class GetAssetsFileUseCase @Inject constructor(
|
||||
private val emulatorRepository: EmulatorRepository
|
||||
) {
|
||||
operator fun invoke(): Flow<File> {
|
||||
return emulatorRepository.getAssetsFile()
|
||||
return emulatorRepository.assetsFile
|
||||
}
|
||||
}
|
||||
@@ -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<List<EmulatorListItem>> {
|
||||
return emulatorRepository.getEmulatorList()
|
||||
operator fun invoke(): Flow<List<EmulatorListItem>> = emulatorRepository.emulatorList.map {
|
||||
it.map { emulator ->
|
||||
EmulatorListItem(
|
||||
id = emulator.id,
|
||||
icon = emulator.iconFile,
|
||||
title = emulator.title,
|
||||
background = emulator.backgroundFile,
|
||||
romsSize = emulator.romsSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Set<String>>
|
||||
) {
|
||||
operator fun invoke(): List<String> {
|
||||
return kotlin.runCatching {
|
||||
val storageManager = context.getSystemService<StorageManager>() ?: return emptyList()
|
||||
suspend operator fun invoke(): List<String> = coroutineScope {
|
||||
kotlin.runCatching {
|
||||
val storageManager = context.getSystemService<StorageManager>() ?: return@coroutineScope emptyList()
|
||||
val clazz = Class.forName("android.os.storage.StorageVolume")
|
||||
val getVolumeListMethod = storageManager.javaClass.getMethod("getVolumeList")
|
||||
val getPathMethod = clazz.getMethod("getPath")
|
||||
|
||||
4
core/src/main/res/values-zh-rCN/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="no_core">没有核心库</string>
|
||||
</resources>
|
||||
4
core/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="no_core">No Core</string>
|
||||
</resources>
|
||||
@@ -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]
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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
|
||||
|
||||
3
gradlew
vendored
@@ -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
|
||||
|
||||
1
libppsspp/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
88
libppsspp/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
0
libppsspp/consumer-rules.pro
Normal file
1536
libppsspp/lint-baseline.xml
Normal file
21
libppsspp/proguard-rules.pro
vendored
Normal file
@@ -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
|
||||
120
libppsspp/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="auto"
|
||||
tools:ignore="MissingLeanbackLauncher">
|
||||
<!-- We use version codes in the format xyyzzrrrr. Example: 110030000
|
||||
In this same case, versionName should be 1.10.3.0
|
||||
Also note that we are overriding these values automatically from a gradle plugin,
|
||||
so we don't need to set them manually. -->
|
||||
|
||||
<uses-feature android:glEsVersion="0x00020000" />
|
||||
<uses-feature android:name="android.hardware.screen.landscape" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.location.network" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.type.pc" android:required="false" />
|
||||
|
||||
<!-- I tried using android:maxSdkVersion="29" on WRITE/READ_EXTERNAL_STORAGE, but that made
|
||||
it so that in legacy mode, you can't ask for permission anymore. So removed that. -->
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="archos.permission.FULLSCREEN.FULL" />
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission-sdk-23 android:name="android.permission.TRANSMIT_IR" />
|
||||
<uses-permission-sdk-23 android:name="android.permission.CAMERA" />
|
||||
<uses-permission-sdk-23 android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!-- AndroidX minimum SDK workaround. We don't care if it's broken on older versions. -->
|
||||
<uses-sdk tools:overrideLibrary="androidx.appcompat.resources,androidx.appcompat,androidx.fragment,androidx.drawerlayout,androidx.vectordrawable.animated,androidx.vectordrawable,androidx.viewpager,androidx.loader,androidx.activity,androidx.annotation,androidx.customview,androidx.cursoradapter,androidx.arch,androidx.collection,androidx.core,androidx.versionedparcelable,androidx.interpolator,androidx.lifecycle,androidx.loader,androidx.savedstate,androidx.lifecycle.viewmodel,androidx.lifecycle.livedata,androidx.lifecycle.livedata.core,androidx.arch.core,androidx.documentfile"/>
|
||||
|
||||
<supports-screens
|
||||
android:largeScreens="true"
|
||||
android:normalScreens="true"
|
||||
android:smallScreens="true"
|
||||
android:xlargeScreens="true" />
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:isGame="true"
|
||||
android:banner="@drawable/ic_banner"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:preserveLegacyExternalStorage="true">
|
||||
<meta-data android:name="android.max_aspect" android:value="2.4" />
|
||||
<activity
|
||||
android:name=".PpssppActivity"
|
||||
android:configChanges="locale|keyboard|keyboardHidden|navigation|uiMode"
|
||||
android:theme="@style/ppsspp_style"
|
||||
android:exported="true">
|
||||
<!-- android:screenOrientation="landscape" -->
|
||||
<!--<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="file" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="*/*" />
|
||||
<data android:host="*" />
|
||||
<data android:pathPattern=".*\\.iso" />
|
||||
<data android:pathPattern=".*\\..*\\.iso" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\.iso" />
|
||||
<data android:pathPattern=".*\\.cso" />
|
||||
<data android:pathPattern=".*\\..*\\.cso" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\.cso" />
|
||||
<data android:pathPattern=".*\\.chd" />
|
||||
<data android:pathPattern=".*\\..*\\.chd" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\.chd" />
|
||||
<data android:pathPattern=".*\\.elf" />
|
||||
<data android:pathPattern=".*\\..*\\.elf" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\.elf" />
|
||||
<data android:pathPattern=".*\\.ISO" />
|
||||
<data android:pathPattern=".*\\..*\\.ISO" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\.ISO" />
|
||||
<data android:pathPattern=".*\\.CSO" />
|
||||
<data android:pathPattern=".*\\..*\\.CSO" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\.CSO" />
|
||||
<data android:pathPattern=".*\\.ELF" />
|
||||
<data android:pathPattern=".*\\..*\\.ELF" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\.ELF" />
|
||||
<data android:pathPattern=".*\\.CHD" />
|
||||
<data android:pathPattern=".*\\..*\\.CHD" />
|
||||
<data android:pathPattern=".*\\..*\\..*\\.CHD" />
|
||||
<data android:pathPattern=".*\\.ppdmp" />
|
||||
<data android:pathPattern=".*\\.pbp" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data android:name="isGame" android:value="true" />
|
||||
<activity
|
||||
android:name=".ShortcutActivity"
|
||||
android:label="@string/shortcut_name"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<action android:name="android.intent.action.CREATE_SHORTCUT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
android:name="xperiaplayoptimized_content"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
|
||||
<profileable android:shell="true" android:enabled="true" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
1
libretroarch/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
75
libretroarch/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
0
libretroarch/consumer-rules.pro
Normal file
21
libretroarch/proguard-rules.pro
vendored
Normal file
@@ -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
|
||||
55
libretroarch/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<!-- <!DOCTYPE manifest [ <!ENTITY % versionDTD SYSTEM "../../../version.dtd"> %versionDTD; ]> !-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.retroarch"
|
||||
android:versionCode="1597175263"
|
||||
android:versionName="1.20.0"
|
||||
android:installLocation="internalOnly">
|
||||
<uses-feature android:glEsVersion="0x00020000" />
|
||||
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false"/>
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application
|
||||
android:name="com.retroarch.playcore.RetroArchApplication"
|
||||
android:allowAudioPlaybackCapture="true"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:hasCode="true"
|
||||
android:isGame="true"
|
||||
android:banner="@drawable/banner"
|
||||
android:extractNativeLibs="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
tools:ignore="UnusedAttribute">
|
||||
<activity android:name="com.retroarch.browser.mainmenu.MainMenuActivity" android:exported="true" android:launchMode="singleInstance">
|
||||
<!--<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<category android:name="tv.ouya.intent.category.GAME" />
|
||||
</intent-filter>-->
|
||||
</activity>
|
||||
<activity android:name="com.retroarch.browser.retroactivity.RetroActivityFuture" android:exported="true" android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale" android:theme="@android:style/Theme.NoTitleBar.Fullscreen" android:launchMode="singleInstance">
|
||||
<meta-data android:name="android.app.lib_name" android:value="retroarch-activity" />
|
||||
<meta-data android:name="android.app.func_name" android:value="ANativeActivity_onCreate" />
|
||||
</activity>
|
||||
<activity android:name="com.retroarch.browser.debug.CoreSideloadActivity" android:exported="true"/>
|
||||
<provider
|
||||
android:name="com.retroarch.browser.provider.RetroDocumentsProvider"
|
||||
android:authorities="${applicationId}.documents"
|
||||
android:grantUriPermissions="true"
|
||||
android:enabled="@bool/document_provider_enabled"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -23,3 +23,5 @@ rootProject.name = "Emulator"
|
||||
include(":app-default")
|
||||
include(":core")
|
||||
include(":ui")
|
||||
include(":libretroarch")
|
||||
include(":libppsspp")
|
||||
|
||||
1
submodules/RetroArch
Submodule
1
submodules/ppsspp
Submodule
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,4 +10,10 @@
|
||||
<string name="dialog_confirm">确定</string>
|
||||
<string name="dialog_cancel">取消</string>
|
||||
<string name="game_size">%s GAMES</string>
|
||||
<string name="menu_toggle">菜单切换</string>
|
||||
<string name="core_select">核心选择</string>
|
||||
<string name="search">搜索</string>
|
||||
<string name="exit_dialog_title">退出</string>
|
||||
<string name="exit_dialog_content">您确定要退出吗?</string>
|
||||
<string name="settings">设置</string>
|
||||
</resources>
|
||||
@@ -10,4 +10,10 @@
|
||||
<string name="dialog_confirm">Confirm</string>
|
||||
<string name="dialog_cancel">Cancel</string>
|
||||
<string name="game_size">%s GAMES</string>
|
||||
<string name="menu_toggle">Menu Toggle</string>
|
||||
<string name="core_select">Core Select</string>
|
||||
<string name="search">Search</string>
|
||||
<string name="exit_dialog_title">Exit</string>
|
||||
<string name="exit_dialog_content">Are you sure to exit?</string>
|
||||
<string name="settings">Settings</string>
|
||||
</resources>
|
||||