This commit is contained in:
2025-03-16 22:53:08 +08:00
parent 680ff4275f
commit cbb57ec109
80 changed files with 4106 additions and 279 deletions

6
.gitmodules vendored Normal file
View 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

View File

@@ -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()
}
}

View 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=" &lt;uses-permission android:name=&quot;android.permission.ACCESS_WIFI_STATE&quot; />"
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>

View File

@@ -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.** { *; }

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -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.
*
* ![Linear progress indicator
* image](https://firebasestorage.googleapis.com/v0/b/design-spec/o/projects%2Fgoogle-material-3%2Fimages%2Flqdiyyvh-1P-progress-indicator-configurations.png?alt=media)
*
* @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)

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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))
}
}

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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 = {}
)
}
)
}
}
}
}
}
}

View File

@@ -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))
}
}
)
}

View File

@@ -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 = {}
)
}

View File

@@ -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
)
}

View File

@@ -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 = {}
)

View File

@@ -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

View File

@@ -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()
)

View File

@@ -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 = {}
)

View File

@@ -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))

View File

@@ -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

View File

@@ -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)
}
}

View 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>

View 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>

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 752 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<root-path
name="storage"
path="/storage/"/>
</paths>

View File

@@ -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)

View File

@@ -0,0 +1,7 @@
package moe.tabidachi.emulator.common
data class AspectRatio(val value: Float) {
companion object {
val AR1_1 = AspectRatio(1f / 1f)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -7,7 +7,7 @@ fun SharedJson() = Json {
isLenient = true
allowSpecialFloatingPointValues = true
allowStructuredMapKeys = true
prettyPrint = false
prettyPrint = true
useArrayPolymorphism = false
ignoreUnknownKeys = true
}

View File

@@ -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"),
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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()
)
}

View File

@@ -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,
)
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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()))
}
}

View File

@@ -9,6 +9,6 @@ class GetAssetsFileUseCase @Inject constructor(
private val emulatorRepository: EmulatorRepository
) {
operator fun invoke(): Flow<File> {
return emulatorRepository.getAssetsFile()
return emulatorRepository.assetsFile
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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")

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="no_core">没有核心库</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="no_core">No Core</string>
</resources>

View File

@@ -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]

View File

@@ -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
View File

@@ -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
View File

@@ -0,0 +1 @@
/build

View 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)
}

View File

1536
libppsspp/lint-baseline.xml Normal file

File diff suppressed because it is too large Load Diff

21
libppsspp/proguard-rules.pro vendored Normal file
View 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

View 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
View File

@@ -0,0 +1 @@
/build

View 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)
}

View File

21
libretroarch/proguard-rules.pro vendored Normal file
View 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

View 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>

View File

@@ -23,3 +23,5 @@ rootProject.name = "Emulator"
include(":app-default")
include(":core")
include(":ui")
include(":libretroarch")
include(":libppsspp")

1
submodules/RetroArch Submodule

Submodule submodules/RetroArch added at a3f1abfad7

1
submodules/ppsspp Submodule

Submodule submodules/ppsspp added at 6423cc9ea5

View File

@@ -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
)
}

View File

@@ -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>

View File

@@ -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>