diff --git a/README.md b/README.md index d396b7b5..6cb99ece 100644 --- a/README.md +++ b/README.md @@ -34,14 +34,14 @@ In no particular order: - [ ] Own custom keyboard overlay, maybe even per-content configs - Not an overlay, and not per-content, but custom keyboard is there - [ ] Error/panic handling -- [ ] Loading "animation" (spinner) +- [X] Loading "animation" (spinner) - [ ] Alternative audio backend (OpenSL ES) for Android < 8 - [ ] Proper storage backend? -- [ ] Resolve design glitches/styling/theming (immersive mode, window insets for holes/notches/corners) +- [X] Resolve design glitches/styling/theming (immersive mode, window insets for holes/notches/corners) - [ ] Publish to various app stores, maybe automatically? -- [ ] Bundle demo animations/games +- [X] Bundle demo animations/games - [ ] Add ability to load content from well known online collections? (well maybe not z0r... unless?) -- [ ] History, favorites, other flair...? +- [X] History, favorites, other flair...? ### DONE: @@ -73,7 +73,7 @@ In no particular order: - [X] Unglitchify audio volume (buttons unresponsive?) - (pending: https://github.com/rust-windowing/winit/pull/1919) - actually solved by switching to GameActivity instead -- [ ] Register Ruffle to open .swf files +- [X] Register Ruffle to open .swf files - How well this works depends on the application opening the file, but it "should work most of the time" - [X] Figure out why videos are not playing (could be a seeking issue) - The video decoder features weren't enabled on `ruffle_core`... diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 072a2497..bf9d62bd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.jetbrainsKotlinAndroid) alias(libs.plugins.cargoNdkAndroid) alias(libs.plugins.composeCompiler) + kotlin("plugin.serialization") version "1.9.10" } android { @@ -105,12 +106,14 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation("androidx.compose.material:material-icons-extended:1.6.0") implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.navigation.runtime.ktx) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.games.activity) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.appcompat) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") androidTestImplementation(libs.androidx.uiautomator) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.rules) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 93a23172..3bde1887 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,5 +65,14 @@ + + + \ No newline at end of file diff --git a/app/src/main/assets/demos/alien_hominid.swf b/app/src/main/assets/demos/alien_hominid.swf new file mode 100644 index 00000000..2788d02b Binary files /dev/null and b/app/src/main/assets/demos/alien_hominid.swf differ diff --git a/app/src/main/assets/demos/bitey1.swf b/app/src/main/assets/demos/bitey1.swf new file mode 100644 index 00000000..fe2183fd Binary files /dev/null and b/app/src/main/assets/demos/bitey1.swf differ diff --git a/app/src/main/assets/demos/flyguy.swf b/app/src/main/assets/demos/flyguy.swf new file mode 100644 index 00000000..fd388ead Binary files /dev/null and b/app/src/main/assets/demos/flyguy.swf differ diff --git a/app/src/main/assets/demos/marvin_spectrum.swf b/app/src/main/assets/demos/marvin_spectrum.swf new file mode 100644 index 00000000..97512659 Binary files /dev/null and b/app/src/main/assets/demos/marvin_spectrum.swf differ diff --git a/app/src/main/assets/demos/saturday_morning_watchmen.swf b/app/src/main/assets/demos/saturday_morning_watchmen.swf new file mode 100644 index 00000000..25bbeab6 Binary files /dev/null and b/app/src/main/assets/demos/saturday_morning_watchmen.swf differ diff --git a/app/src/main/assets/demos/synj1.swf b/app/src/main/assets/demos/synj1.swf new file mode 100644 index 00000000..4fec2eb6 Binary files /dev/null and b/app/src/main/assets/demos/synj1.swf differ diff --git a/app/src/main/assets/demos/synj2.swf b/app/src/main/assets/demos/synj2.swf new file mode 100644 index 00000000..bea2d5d8 Binary files /dev/null and b/app/src/main/assets/demos/synj2.swf differ diff --git a/app/src/main/assets/demos/wasted_sky.swf b/app/src/main/assets/demos/wasted_sky.swf new file mode 100644 index 00000000..648b92b5 Binary files /dev/null and b/app/src/main/assets/demos/wasted_sky.swf differ diff --git a/app/src/main/java/rs/ruffle/DemosManager.kt b/app/src/main/java/rs/ruffle/DemosManager.kt new file mode 100644 index 00000000..e03ee1c8 --- /dev/null +++ b/app/src/main/java/rs/ruffle/DemosManager.kt @@ -0,0 +1,224 @@ +package rs.ruffle + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.mutableStateListOf +import androidx.core.content.FileProvider +import java.io.File +import java.io.FileOutputStream + +/** + * Manages bundled demo SWF files + */ +object DemosManager { + private val demoEntries = mutableStateListOf() + private const val DEMOS_DIRECTORY = "demos" + + data class DemoEntry( + val name: String, + val assetPath: String, + val description: String, + val category: DemoCategory + ) + + enum class DemoCategory { + ANIMATION, + GAME + } + + // Map of filename to proper display name and category + private val demoMetadata = mapOf( + "bitey1.swf" to Pair("Bitey of Brackenwood", DemoCategory.ANIMATION), + "saturday_morning_watchmen.swf" to Pair( + "Saturday Morning Watchmen", + DemoCategory.ANIMATION + ), + "synj1.swf" to Pair("Synj vs. Horrid Part 1", DemoCategory.ANIMATION), + "synj2.swf" to Pair("Synj vs. Horrid Part 2", DemoCategory.ANIMATION), + "alien_hominid.swf" to Pair("Alien Hominid", DemoCategory.GAME), + "flyguy.swf" to Pair("FlyGuy", DemoCategory.GAME), + "marvin_spectrum.swf" to Pair("Marvin Spectrum", DemoCategory.GAME), + "wasted_sky.swf" to Pair("Wasted Sky", DemoCategory.GAME) + ) + + /** + * Load demo files from assets + */ + fun loadDemos(context: Context) { + try { + // Clear previous entries + demoEntries.clear() + + // Check if the demos directory exists + try { + // Get list of files in the demos directory + val demoFiles = context.assets.list(DEMOS_DIRECTORY) + + if (demoFiles != null && demoFiles.isNotEmpty()) { + Log.d("DemosManager", "Found ${demoFiles.size} files in demos directory") + + // Add each .swf file as a demo entry + for (file in demoFiles) { + if (file.endsWith(".swf", ignoreCase = true)) { + val path = "$DEMOS_DIRECTORY/$file" + + // Get proper name and category from metadata or generate default + val (displayName, category) = demoMetadata[file] ?: Pair( + file.substringBeforeLast(".").replace("_", " ").capitalize(), + DemoCategory.ANIMATION + // Default category if unknown + ) + + // Verify file can be opened + try { + context.assets.open(path).close() + + // Create appropriate description based on category + val description = when (category) { + DemoCategory.ANIMATION -> "Flash Animation" + DemoCategory.GAME -> "Flash Game" + } + + demoEntries.add( + DemoEntry( + displayName, + path, + description, + category + ) + ) + Log.d( + "DemosManager", + "Added demo: $displayName (${category.name}) from $path" + ) + } catch (e: Exception) { + Log.e("DemosManager", "Error verifying demo file $path", e) + } + } + } + } else { + Log.w( + "DemosManager", + "No files found in demos directory or directory doesn't exist" + ) + } + } catch (e: Exception) { + Log.e("DemosManager", "Error listing demos directory", e) + } + + // If no demos found in assets, log this instead of adding placeholder + if (demoEntries.isEmpty()) { + Log.w("DemosManager", "No demo SWF files found in assets") + } + + Log.d("DemosManager", "Loaded ${demoEntries.size} demo entries") + } catch (e: Exception) { + Log.e("DemosManager", "Error loading demos", e) + } + } + + fun getDemoEntries(): List = demoEntries + + fun getAnimationDemos(): List = + demoEntries.filter { it.category == DemoCategory.ANIMATION } + + fun getGameDemos(): List = demoEntries.filter { it.category == DemoCategory.GAME } + + /** + * Extracts a demo SWF file to a temporary file and returns a Uri to it + */ + fun extractDemoToFile(context: Context, assetPath: String): Uri? { + try { + // First check if the asset actually exists + try { + // This will throw an exception if the asset doesn't exist + context.assets.open(assetPath).close() + } catch (e: Exception) { + Log.e("DemosManager", "Demo file $assetPath doesn't exist in assets", e) + return null + } + + val tempFile = File(context.cacheDir, "demos/${File(assetPath).name}") + if (!tempFile.parentFile?.exists()!!) { + val dirCreated = tempFile.parentFile?.mkdirs() + if (dirCreated != true) { + Log.e("DemosManager", "Failed to create parent directory for $tempFile") + return null + } + } + + // If the file already exists, delete it to avoid issues + if (tempFile.exists()) { + tempFile.delete() + } + + try { + context.assets.open(assetPath).use { inputStream -> + FileOutputStream(tempFile).use { outputStream -> + val buffer = ByteArray(1024) + var length: Int + var totalBytes = 0 + + while (inputStream.read(buffer).also { length = it } > 0) { + outputStream.write(buffer, 0, length) + totalBytes += length + } + + outputStream.flush() + Log.d( + "DemosManager", + "Successfully extracted $assetPath to $tempFile ($totalBytes bytes)" + ) + } + } + + // Verify the file was created successfully + if (!tempFile.exists() || tempFile.length() == 0L) { + Log.e("DemosManager", "Extraction completed but file is empty or doesn't exist") + return null + } + + // Ensure the file is readable + if (!tempFile.canRead()) { + Log.e("DemosManager", "Extracted file is not readable: $tempFile") + return null + } + + // Set read permissions for all + tempFile.setReadable(true, false) + + // Use FileProvider to create a content:// URI instead of file:// URI + val contentUri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + tempFile + ) + + // Grant read permission for the content URI + context.grantUriPermission( + context.packageName, + contentUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + Log.d("DemosManager", "Created content URI: $contentUri for file: $tempFile") + return contentUri + } catch (e: Exception) { + Log.e("DemosManager", "Error extracting demo file $assetPath", e) + return null + } + } catch (e: Exception) { + Log.e("DemosManager", "Error creating temp file", e) + return null + } + } + + // Helper function to capitalize first letter of words + private fun String.capitalize(): String { + return this.split(" ").joinToString(" ") { word -> + if (word.isNotEmpty()) word[0].uppercase() + word.substring(1) else "" + } + } +} diff --git a/app/src/main/java/rs/ruffle/MainActivity.kt b/app/src/main/java/rs/ruffle/MainActivity.kt index ed1cb17e..6bdaeb7f 100644 --- a/app/src/main/java/rs/ruffle/MainActivity.kt +++ b/app/src/main/java/rs/ruffle/MainActivity.kt @@ -3,9 +3,12 @@ package rs.ruffle import android.content.Intent import android.net.Uri import android.os.Bundle +import android.util.Log +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import java.io.File import rs.ruffle.ui.theme.RuffleTheme class MainActivity : ComponentActivity() { @@ -13,6 +16,16 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) + // Verify demos directory exists in assets or cache + verifyDemosDirectory() + + // Load SWF history and favorites data + SwfHistoryManager.loadHistory(this) + SwfFavoritesManager.loadFavorites(this) + + // Load bundled demo SWF files + DemosManager.loadDemos(this) + setContent { RuffleTheme { RuffleNavHost(openSwf = { openSwf(it) }) @@ -20,6 +33,74 @@ class MainActivity : ComponentActivity() { } } + private fun verifyDemosDirectory() { + try { + // Check if demos directory exists + val demoFiles = assets.list("demos") + + if (demoFiles == null || demoFiles.isEmpty()) { + Log.w( + "MainActivity", + "No demos directory or it's empty. Creating cache directory for demos." + ) + + // Create a demos directory in the cache folder so we can still show the demos UI section + val demosDir = File(cacheDir, "demos") + if (!demosDir.exists()) { + demosDir.mkdirs() + } + + // Inform the user about missing demos + Toast.makeText( + this, + "No demo Flash files found. The demo section will be empty.", + Toast.LENGTH_LONG + ).show() + } else { + Log.d("MainActivity", "Found ${demoFiles.size} files in demos directory") + + // Verify that at least one .swf file exists + var hasSWF = false + for (file in demoFiles) { + if (file.endsWith(".swf", ignoreCase = true)) { + try { + assets.open("demos/$file").close() + hasSWF = true + break + } catch (e: Exception) { + Log.e("MainActivity", "Error verifying demo file demos/$file", e) + } + } + } + + if (!hasSWF) { + Log.w("MainActivity", "No .swf files found in demos directory") + + // Inform the user about no SWF files + Toast.makeText( + this, + "Demo section will not work: no valid Flash files found.", + Toast.LENGTH_LONG + ).show() + } else { + Log.d( + "MainActivity", + "Found SWF files in demos directory - demo section will work" + ) + } + } + } catch (e: Exception) { + Log.e("MainActivity", "Error verifying demos directory", e) + + // Inform user about the error + Toast.makeText( + this, + "Error checking for demo files: ${e.localizedMessage}", + Toast.LENGTH_LONG + ).show() + } + } + private fun openSwf(uri: Uri) { val intent = Intent( this@MainActivity, diff --git a/app/src/main/java/rs/ruffle/PlayerActivity.kt b/app/src/main/java/rs/ruffle/PlayerActivity.kt index e19fd879..e6f3d84b 100644 --- a/app/src/main/java/rs/ruffle/PlayerActivity.kt +++ b/app/src/main/java/rs/ruffle/PlayerActivity.kt @@ -3,10 +3,13 @@ package rs.ruffle import android.annotation.SuppressLint import android.content.Intent import android.content.res.Configuration +import android.graphics.Color import android.net.Uri import android.os.Build import android.os.Build.VERSION_CODES import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log import android.view.Menu import android.view.MenuItem @@ -16,7 +19,9 @@ import android.view.ViewGroup import android.view.Window import android.view.WindowManager import android.widget.Button +import android.widget.ImageButton import android.widget.PopupMenu +import android.widget.ProgressBar import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat @@ -25,7 +30,6 @@ import androidx.core.view.WindowInsetsControllerCompat import com.google.androidgamesdk.GameActivity import java.io.DataInputStream import java.io.File -import java.io.IOException class PlayerActivity : GameActivity() { @Suppress("unused") @@ -33,29 +37,167 @@ class PlayerActivity : GameActivity() { private val swfBytes: ByteArray? get() { val uri = intent.data - if (uri?.scheme == "content") { - try { - contentResolver.openInputStream(uri).use { inputStream -> - if (inputStream == null) { + if (uri == null) { + Log.e("PlayerActivity", "No URI provided in intent") + return null + } + + try { + Log.d( + "PlayerActivity", + "Attempting to load SWF from URI: $uri (scheme: ${uri.scheme})" + ) + + when (uri.scheme) { + "content" -> { + try { + // For content:// URIs (including those from FileProvider) + contentResolver.openInputStream(uri).use { inputStream -> + if (inputStream == null) { + Log.e("PlayerActivity", "Failed to open content URI: $uri") + return null + } + + // Get the size first + val size = inputStream.available() + Log.d( + "PlayerActivity", + "Content URI input stream available size: $size bytes" + ) + + // Creating byte array of exact size + val bytes = ByteArray(size) + val dataInputStream = DataInputStream(inputStream) + val bytesRead = dataInputStream.read(bytes) + + Log.d( + "PlayerActivity", + "Successfully read content URI: $uri (read $bytesRead of $size bytes)" + ) + + if (bytesRead <= 0 || bytesRead < size) { + Log.e( + "PlayerActivity", + "Failed to read complete content from URI: $uri ($bytesRead of $size bytes)" + ) + // Try to continue anyway with what we have + } + + // Add to history + swfUri = uri + addToHistory(uri) + + return bytes + } + } catch (e: Exception) { + Log.e("PlayerActivity", "Error reading content URI: $uri", e) + return null + } + } + "file" -> { + try { + // Handle file:// URIs (although these should be avoided in favor of content:// URIs) + val filePath = uri.path + if (filePath == null) { + Log.e("PlayerActivity", "File URI has no path: $uri") + return null + } + + val file = File(filePath) + if (!file.exists()) { + Log.e("PlayerActivity", "File doesn't exist: $filePath") + return null + } + + if (!file.canRead()) { + Log.e("PlayerActivity", "File isn't readable: $filePath") + // Try to make it readable + file.setReadable(true, false) + if (!file.canRead()) { + Log.e( + "PlayerActivity", + "Failed to make file readable: $filePath" + ) + return null + } + } + + Log.d( + "PlayerActivity", + "Reading file directly: $filePath (${file.length()} bytes)" + ) + + file.inputStream().use { inputStream -> + val size = inputStream.available() + val bytes = ByteArray(size) + val dataInputStream = DataInputStream(inputStream) + val bytesRead = dataInputStream.read(bytes) + + Log.d( + "PlayerActivity", + "Successfully read file: $filePath (read $bytesRead of $size bytes)" + ) + + // Add to history + swfUri = uri + addToHistory(uri) + + return bytes + } + } catch (e: Exception) { + Log.e("PlayerActivity", "Error reading file URI: $uri", e) return null } - val bytes = ByteArray(inputStream.available()) - val dataInputStream = DataInputStream(inputStream) - dataInputStream.readFully(bytes) - return bytes } - } catch (ignored: IOException) { + else -> { + Log.e("PlayerActivity", "Unsupported URI scheme: ${uri.scheme}") + return null + } } + } catch (e: Exception) { + Log.e("PlayerActivity", "Unexpected error loading SWF", e) + return null } + return null } @Suppress("unused") // Used by Rust - private val swfUri: String? - get() { - return intent.dataString + private fun getSwfUriString(): String? { + val uri = intent.data + if (uri != null) { + // Add to history + swfUri = uri + addToHistory(uri) } + return intent.dataString + } + + @JvmName("getSwfUri") + @Suppress("unused") + // Used by Rust + public fun getSwfUri(): String? { + Log.d("PlayerActivity", "getSwfUri() called from native code") + if (swfUri == null) { + swfUri = intent.data + Log.d("PlayerActivity", "getSwfUri() initialized from intent: $swfUri") + } else { + Log.d("PlayerActivity", "getSwfUri() returning existing uri: $swfUri") + } + return swfUri?.toString() + } + + @JvmName("getSwfUriObject") + @Suppress("unused") + // Used by Rust (alternative method if needed) + public fun getSwfUriObject(): Uri? { + Log.d("PlayerActivity", "getSwfUriObject() called") + if (swfUri == null) { + swfUri = intent.data + } + return swfUri + } @Suppress("unused") // Used by Rust @@ -151,19 +293,73 @@ class PlayerActivity : GameActivity() { return storageDirPath } + private lateinit var loadingSpinner: ProgressBar + private var isContentLoaded = false + private lateinit var favoriteButton: ImageButton + private var isFavorite = false + private var swfUri: Uri? = null + override fun onCreateSurfaceView() { val inflater = layoutInflater @SuppressLint("InflateParams") val layout = inflater.inflate(R.layout.keyboard, null) as ConstraintLayout - contentViewId = ViewCompat.generateViewId() + contentViewId = View.generateViewId() layout.id = contentViewId setContentView(layout) mSurfaceView = InputEnabledSurfaceView(this) mSurfaceView.contentDescription = "Ruffle Player" + // Create and add loading spinner with improved styling + loadingSpinner = ProgressBar(this, null, android.R.attr.progressBarStyleLarge) + loadingSpinner.isIndeterminate = true + + // Set up an overlay background for the spinner to make it stand out + val overlayBackground = View(this) + overlayBackground.setBackgroundColor(Color.BLACK) + overlayBackground.alpha = 0.5f + + val spinnerParams = ConstraintLayout.LayoutParams( + ConstraintLayout.LayoutParams.WRAP_CONTENT, + ConstraintLayout.LayoutParams.WRAP_CONTENT + ) + spinnerParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID + spinnerParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + spinnerParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID + spinnerParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID + loadingSpinner.layoutParams = spinnerParams + + val overlayParams = ConstraintLayout.LayoutParams( + ConstraintLayout.LayoutParams.MATCH_PARENT, + ConstraintLayout.LayoutParams.MATCH_PARENT + ) + overlayBackground.layoutParams = overlayParams + + layout.addView(overlayBackground) + layout.addView(loadingSpinner) + + // Add favorite button + favoriteButton = ImageButton(this) + favoriteButton.setImageResource(android.R.drawable.btn_star_big_off) + favoriteButton.setBackgroundResource(android.R.drawable.btn_default) + + val favoriteParams = ConstraintLayout.LayoutParams( + ConstraintLayout.LayoutParams.WRAP_CONTENT, + ConstraintLayout.LayoutParams.WRAP_CONTENT + ) + favoriteParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID + favoriteParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID + favoriteParams.setMargins(0, 16, 16, 0) // left, top, right, bottom + favoriteButton.layoutParams = favoriteParams + + favoriteButton.setOnClickListener { + toggleFavorite() + } + + layout.addView(favoriteButton) + val placeholder = findViewById(R.id.placeholder) val pars = placeholder.layoutParams as ConstraintLayout.LayoutParams val parent = placeholder.parent as ViewGroup @@ -217,6 +413,17 @@ class PlayerActivity : GameActivity() { window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS } + + // Set navigation bar transparency for better immersive experience + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + @Suppress("DEPRECATION") + window.setDecorFitsSystemWindows(false) + window.insetsController?.hide(WindowInsetsCompat.Type.systemBars()) + } else { + @Suppress("DEPRECATION") + window.navigationBarColor = Color.TRANSPARENT + } + // From API 30 onwards, this is the recommended way to hide the system UI, rather than // using View.setSystemUiVisibility. val decorView = window.decorView @@ -228,9 +435,41 @@ class PlayerActivity : GameActivity() { controller.hide(WindowInsetsCompat.Type.displayCutout()) controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + // For devices with display cutouts, ensure content renders properly + window.decorView.setOnApplyWindowInsetsListener { view, insets -> + view.onApplyWindowInsets(insets) + insets + } } override fun onCreate(savedInstanceState: Bundle?) { + // Load history data + SwfHistoryManager.loadHistory(this) + SwfFavoritesManager.loadFavorites(this) + + // Get intent data and log it for debugging + swfUri = intent.data + Log.d( + "PlayerActivity", + "onCreate: intent.data = ${intent.data}, intent.dataString = ${intent.dataString}" + ) + + if (swfUri != null) { + Log.d("PlayerActivity", "Found SWF URI in intent: $swfUri (scheme: ${swfUri!!.scheme})") + isFavorite = SwfFavoritesManager.isFavorite(swfUri.toString()) + } else { + Log.e("PlayerActivity", "No SWF URI found in intent!") + } + + // Set a fallback timer to hide the loading spinner after a timeout + Handler(Looper.getMainLooper()).postDelayed({ + if (!isContentLoaded) { + Log.d("PlayerActivity", "Fallback timer: hiding loading spinner after timeout") + hideLoadingSpinner() + } + }, 10000) // 10 seconds timeout + nativeInit { message -> Log.e("ruffle", "Handling panic: $message") startActivity( @@ -269,6 +508,68 @@ class PlayerActivity : GameActivity() { ).hide(WindowInsetsCompat.Type.statusBars()) } + // New method to be called from Rust when content is ready + @JvmName("onContentReady") + @Suppress("unused") + // Used by Rust + public fun onContentReady() { + Log.d("PlayerActivity", "onContentReady() called from native code") + runOnUiThread { + isContentLoaded = true + hideLoadingSpinner() + } + } + + private fun hideLoadingSpinner() { + // Update favorite button with current status + if (swfUri != null) { + isFavorite = SwfFavoritesManager.isFavorite(swfUri.toString()) + updateFavoriteButton() + } + + // Find and remove the overlay background too + val parent = loadingSpinner.parent as? ViewGroup + parent?.let { + for (i in 0 until it.childCount) { + val child = it.getChildAt(i) + if (child is View && child.background != null && child.alpha < 1.0f) { + Log.d("PlayerActivity", "Hiding overlay background") + child.visibility = View.GONE + } + } + } + + Log.d("PlayerActivity", "Hiding loading spinner") + loadingSpinner.visibility = View.GONE + } + + private fun toggleFavorite() { + val uri = swfUri ?: return + val displayName = getDisplayNameFromUri(uri) ?: uri.lastPathSegment ?: "Unknown SWF" + + isFavorite = if (SwfFavoritesManager.isFavorite(uri.toString())) { + // Remove from favorites + SwfFavoritesManager.removeFromFavorites(this, uri.toString()) + false + } else { + // Add to favorites + SwfFavoritesManager.addToFavorites(this, uri, displayName) + true + } + + // Update button appearance + updateFavoriteButton() + } + + private fun updateFavoriteButton() { + val iconResource = if (isFavorite) { + android.R.drawable.btn_star_big_on + } else { + android.R.drawable.btn_star_big_off + } + favoriteButton.setImageResource(iconResource) + } + companion object { init { // load the native activity @@ -294,4 +595,26 @@ class PlayerActivity : GameActivity() { fun interface CrashCallback { fun onCrash(message: String) } + + // Record SWF file access in history + private fun addToHistory(uri: Uri) { + val displayName = getDisplayNameFromUri(uri) ?: uri.lastPathSegment ?: "Unknown SWF" + SwfHistoryManager.addToHistory(this, uri, displayName) + } + + // Get a friendly display name for the SWF file + private fun getDisplayNameFromUri(uri: Uri): String? { + if (uri.scheme == "content") { + val cursor = contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val displayNameIndex = it.getColumnIndex("_display_name") + if (displayNameIndex != -1) { + return it.getString(displayNameIndex) + } + } + } + } + return uri.lastPathSegment + } } diff --git a/app/src/main/java/rs/ruffle/SelectSwfScreen.kt b/app/src/main/java/rs/ruffle/SelectSwfScreen.kt index 81692123..f63991cd 100644 --- a/app/src/main/java/rs/ruffle/SelectSwfScreen.kt +++ b/app/src/main/java/rs/ruffle/SelectSwfScreen.kt @@ -1,23 +1,46 @@ package rs.ruffle +import android.content.Intent import android.content.res.Configuration import android.net.Uri +import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.paddingFromBaseline +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Videocam import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -38,6 +61,9 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import rs.ruffle.ui.theme.RuffleTheme import rs.ruffle.ui.theme.SLIGHTLY_DEEMPHASIZED_ALPHA @@ -61,12 +87,18 @@ fun SelectSwfRoute(openSwf: (uri: Uri) -> Unit) { @Composable fun SelectSwfScreen(openSwf: (uri: Uri) -> Unit) { + val scrollState = rememberScrollState() + Scaffold { innerPadding -> Column( modifier = Modifier .padding(innerPadding) - .fillMaxSize(), - verticalArrangement = Arrangement.Center + .fillMaxSize() + .verticalScroll(scrollState) + .statusBarsPadding() + .navigationBarsPadding() + .imePadding(), + verticalArrangement = Arrangement.Top ) { Column( modifier = Modifier @@ -79,11 +111,39 @@ fun SelectSwfScreen(openSwf: (uri: Uri) -> Unit) { modifier = Modifier .align(Alignment.CenterHorizontally) .wrapContentSize(align = Alignment.Center) - .padding(horizontal = 8.dp, vertical = 20.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), text = stringResource(id = R.string.work_in_progress_warning), - textAlign = TextAlign.Center + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium ) + SelectSwfUrlOrFile(openSwf) + + // Add spacing between sections + Spacer(modifier = Modifier.height(16.dp)) + + // Display favorites section if there are entries + val favoriteEntries = SwfFavoritesManager.getFavoriteEntries() + if (favoriteEntries.isNotEmpty()) { + FavoritesSection(favoriteEntries, openSwf) + // Add spacing between sections + Spacer(modifier = Modifier.height(8.dp)) + } + + // Display bundled demos section + DemosSection(openSwf) + + // Add spacing between sections + Spacer(modifier = Modifier.height(8.dp)) + + // Display history section if there are entries + val historyEntries = SwfHistoryManager.getHistoryEntries() + if (historyEntries.isNotEmpty()) { + HistorySection(historyEntries, openSwf) + } + + // Add bottom padding for better scrolling experience + Spacer(modifier = Modifier.height(16.dp)) } } } @@ -211,6 +271,399 @@ fun TextFieldError(textError: String) { } } +@Composable +fun DemosSection(openSwf: (Uri) -> Unit) { + val context = androidx.compose.ui.platform.LocalContext.current + val gameDemos = DemosManager.getGameDemos() + val animationDemos = DemosManager.getAnimationDemos() + + if (gameDemos.isNotEmpty() || animationDemos.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = stringResource(id = R.string.demos_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Games section + if (gameDemos.isNotEmpty()) { + DemoCategorySection( + titleResId = R.string.demos_games_title, + icon = Icons.Filled.PlayArrow, + demos = gameDemos, + openSwf = { demo -> + val uri = DemosManager.extractDemoToFile(context, demo.assetPath) + if (uri != null) { + Log.d("SelectSwfScreen", "Opening demo game: $uri (${demo.name})") + // Explicitly specify intent data to ensure it's properly passed to PlayerActivity + val intent = Intent(context, PlayerActivity::class.java).apply { + data = uri + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(intent) + } else { + // Show error toast + android.widget.Toast.makeText( + context, + "Failed to load game: ${demo.name}", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + ) + } + + // Add space between sections + if (gameDemos.isNotEmpty() && animationDemos.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + } + + // Animations section + if (animationDemos.isNotEmpty()) { + DemoCategorySection( + titleResId = R.string.demos_animations_title, + icon = Icons.Filled.Videocam, + demos = animationDemos, + openSwf = { demo -> + val uri = DemosManager.extractDemoToFile(context, demo.assetPath) + if (uri != null) { + Log.d("SelectSwfScreen", "Opening demo animation: $uri (${demo.name})") + // Explicitly specify intent data to ensure it's properly passed to PlayerActivity + val intent = Intent(context, PlayerActivity::class.java).apply { + data = uri + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(intent) + } else { + // Show error toast + android.widget.Toast.makeText( + context, + "Failed to load animation: ${demo.name}", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + ) + } + } + } +} + +@Composable +fun DemoCategorySection( + titleResId: Int, + icon: androidx.compose.ui.graphics.vector.ImageVector, + demos: List, + openSwf: (DemosManager.DemoEntry) -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = stringResource(id = titleResId), + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(id = titleResId), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + LazyColumn( + modifier = Modifier + .heightIn(max = 200.dp) + ) { + items(demos) { entry -> + DemoItem(entry) { + openSwf(entry) + } + } + } + } + } +} + +@Composable +fun DemoItem(entry: DemosManager.DemoEntry, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { onClick() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.name, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = entry.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = SLIGHTLY_DEEMPHASIZED_ALPHA + ) + ) + } + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = stringResource(id = R.string.open_swf) + ) + } + } + } +} + +@Composable +fun FavoritesSection(favoriteEntries: List, openSwf: (Uri) -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = stringResource(id = R.string.favorites_title), + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(id = R.string.favorites_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + val context = androidx.compose.ui.platform.LocalContext.current + IconButton(onClick = { SwfFavoritesManager.clearFavorites(context) }) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = stringResource(id = R.string.clear_favorites) + ) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + if (favoriteEntries.isEmpty()) { + Text( + text = stringResource(id = R.string.no_favorites), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(vertical = 16.dp) + .align(Alignment.CenterHorizontally) + ) + } else { + LazyColumn( + modifier = Modifier + .heightIn(max = 300.dp) + ) { + items(favoriteEntries) { entry -> + FavoriteItem(entry) { + openSwf(Uri.parse(entry.uri)) + } + } + } + } + } + } +} + +@Composable +fun FavoriteItem(entry: SwfFavoriteEntry, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { onClick() } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold + ) + if (entry.notes.isNotEmpty()) { + Text( + text = entry.notes, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = SLIGHTLY_DEEMPHASIZED_ALPHA + ) + ) + } + } + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = stringResource(id = R.string.open_swf) + ) + } + } + } + } +} + +@Composable +fun HistorySection(historyEntries: List, openSwf: (Uri) -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.History, + contentDescription = stringResource(id = R.string.history_title), + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(id = R.string.history_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + val context = androidx.compose.ui.platform.LocalContext.current + IconButton(onClick = { SwfHistoryManager.clearHistory(context) }) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = stringResource(id = R.string.clear_history) + ) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + if (historyEntries.isEmpty()) { + Text( + text = stringResource(id = R.string.no_history), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(vertical = 16.dp) + .align(Alignment.CenterHorizontally) + ) + } else { + LazyColumn( + modifier = Modifier + .heightIn(max = 300.dp) + ) { + items(historyEntries) { entry -> + HistoryItem(entry) { + openSwf(Uri.parse(entry.uri)) + } + } + } + } + } + } +} + +@Composable +fun HistoryItem(entry: SwfHistoryEntry, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { onClick() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.displayName, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = formatTimestamp(entry.timestamp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = SLIGHTLY_DEEMPHASIZED_ALPHA + ) + ) + } + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = stringResource(id = R.string.open_swf) + ) + } + } + } +} + +fun formatTimestamp(timestamp: Long): String { + val date = Date(timestamp) + val formatter = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()) + return formatter.format(date) +} + @Preview(name = "Select SWF - Light", uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "Select SWF - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/app/src/main/java/rs/ruffle/SwfFavoritesModel.kt b/app/src/main/java/rs/ruffle/SwfFavoritesModel.kt new file mode 100644 index 00000000..9c55b887 --- /dev/null +++ b/app/src/main/java/rs/ruffle/SwfFavoritesModel.kt @@ -0,0 +1,97 @@ +package rs.ruffle + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.mutableStateListOf +import java.io.File +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Represents a favorite SWF file entry + */ +@Serializable +data class SwfFavoriteEntry( + val uri: String, + val displayName: String, + val notes: String = "", + val timestamp: Long = System.currentTimeMillis() +) { + constructor(uri: Uri, displayName: String, notes: String = "") : this( + uri.toString(), + displayName, + notes, + System.currentTimeMillis() + ) +} + +/** + * Manages favorites of SWF files + */ +object SwfFavoritesManager { + private const val MAX_FAVORITES = 50 + private const val FAVORITES_FILE_NAME = "swf_favorites.json" + private val favoriteEntries = mutableStateListOf() + private val json = Json { prettyPrint = true } + + fun getFavoriteEntries(): List = favoriteEntries + + fun addToFavorites(context: Context, uri: Uri, displayName: String, notes: String = "") { + // If already exists, remove it first (will be re-added) + favoriteEntries.removeAll { it.uri == uri.toString() } + + // Add new entry + favoriteEntries.add(0, SwfFavoriteEntry(uri, displayName, notes)) + + // Make sure we don't exceed our limit + while (favoriteEntries.size > MAX_FAVORITES) { + favoriteEntries.removeAt(favoriteEntries.size - 1) + } + + // Save to storage + saveFavorites(context) + } + + fun removeFromFavorites(context: Context, uri: String) { + val entryRemoved = favoriteEntries.removeAll { it.uri == uri } + if (entryRemoved) { + saveFavorites(context) + } + } + + fun isFavorite(uri: String): Boolean { + return favoriteEntries.any { it.uri == uri } + } + + fun loadFavorites(context: Context) { + try { + val file = File(context.filesDir, FAVORITES_FILE_NAME) + if (file.exists()) { + val jsonString = file.readText() + val loadedEntries = json.decodeFromString>(jsonString) + + favoriteEntries.clear() + favoriteEntries.addAll(loadedEntries) + } + } catch (e: Exception) { + Log.e("SwfFavoritesManager", "Error loading favorites", e) + } + } + + private fun saveFavorites(context: Context) { + try { + val file = File(context.filesDir, FAVORITES_FILE_NAME) + val jsonString = json.encodeToString(favoriteEntries) + file.writeText(jsonString) + } catch (e: Exception) { + Log.e("SwfFavoritesManager", "Error saving favorites", e) + } + } + + fun clearFavorites(context: Context) { + favoriteEntries.clear() + saveFavorites(context) + } +} diff --git a/app/src/main/java/rs/ruffle/SwfHistoryModel.kt b/app/src/main/java/rs/ruffle/SwfHistoryModel.kt new file mode 100644 index 00000000..43b4789f --- /dev/null +++ b/app/src/main/java/rs/ruffle/SwfHistoryModel.kt @@ -0,0 +1,81 @@ +package rs.ruffle + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.mutableStateListOf +import java.io.File +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +data class SwfHistoryEntry( + val uri: String, + val displayName: String, + val timestamp: Long +) { + constructor(uri: Uri, displayName: String) : this( + uri.toString(), + displayName, + System.currentTimeMillis() + ) +} + +/** + * Manages history of accessed SWF files + */ +object SwfHistoryManager { + private const val MAX_HISTORY_ENTRIES = 10 + private const val HISTORY_FILE_NAME = "swf_history.json" + private val historyEntries = mutableStateListOf() + private val json = Json { prettyPrint = true } + + fun getHistoryEntries(): List = historyEntries + + fun addToHistory(context: Context, uri: Uri, displayName: String) { + // Remove existing entry with the same URI if present + historyEntries.removeAll { it.uri == uri.toString() } + + // Add new entry at the beginning + historyEntries.add(0, SwfHistoryEntry(uri, displayName)) + + // Trim list if too long + while (historyEntries.size > MAX_HISTORY_ENTRIES) { + historyEntries.removeAt(historyEntries.size - 1) + } + + // Save to storage + saveHistory(context) + } + + fun loadHistory(context: Context) { + try { + val file = File(context.filesDir, HISTORY_FILE_NAME) + if (file.exists()) { + val jsonString = file.readText() + val loadedEntries = json.decodeFromString>(jsonString) + + historyEntries.clear() + historyEntries.addAll(loadedEntries) + } + } catch (e: Exception) { + Log.e("SwfHistoryManager", "Error loading history", e) + } + } + + private fun saveHistory(context: Context) { + try { + val file = File(context.filesDir, HISTORY_FILE_NAME) + val jsonString = json.encodeToString(historyEntries) + file.writeText(jsonString) + } catch (e: Exception) { + Log.e("SwfHistoryManager", "Error saving history", e) + } + } + + fun clearHistory(context: Context) { + historyEntries.clear() + saveHistory(context) + } +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d76a0d57..273c0ee3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,9 +1,29 @@ + Ruffle - Ruffle Logo - 或者 - 打开链接 - 选择本地swf文件 - 请输入网页链接 - 这款应用还在开发中,一些功能可能尚未实现! + Ruffle 标志 + + 打开 URL + 选择 SWF 文件 + URL + 此应用程序正在开发中。有些功能可能尚未实现! + + + 最近打开 + 清除历史记录 + 打开 SWF + 暂无历史记录 + + + 演示内容 + 没有可用的演示内容 + 游戏 + 动画 + + + 收藏夹 + 清空收藏夹 + 暂无收藏内容 + 添加到收藏夹 + 从收藏夹中移除 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 645c0176..3d0121f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,4 +6,23 @@ Select a SWF URL This app is a work in progress. Some functionality may not be implemented yet! + + + Recently Opened + Clear History + Open SWF + No history yet + + + Demo Content + No demo content available + Games + Animations + + + Favorites + Clear Favorites + No favorites yet + Add to favorites + Remove from favorites \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..57fae3a9 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/java.rs b/src/java.rs index 3bbde2b5..3c075c13 100644 --- a/src/java.rs +++ b/src/java.rs @@ -20,6 +20,7 @@ pub struct JavaInterface { get_trace_output: JMethodID, get_loc_in_window: JMethodID, get_android_data_storage_dir: JMethodID, + on_content_ready: JMethodID, } static JAVA_INTERFACE: OnceLock = OnceLock::new(); @@ -171,6 +172,18 @@ impl JavaInterface { .expect("Java interface must have been created via nativeInit()") } + pub fn on_content_ready(env: &mut JNIEnv, this: &JObject) { + let result = unsafe { + env.call_method_unchecked( + this, + Self::get().on_content_ready, + ReturnType::Primitive(Primitive::Void), + &[], + ) + }; + result.expect("onContentReady() must never throw"); + } + pub fn init(env: &mut JNIEnv, class: &JClass) { let _ = JAVA_INTERFACE.set(JavaInterface { get_surface_width: env @@ -197,6 +210,9 @@ impl JavaInterface { get_android_data_storage_dir: env .get_method_id(class, "getAndroidDataStorageDir", "()Ljava/lang/String;") .expect("getAndroidDataStorageDir must exist"), + on_content_ready: env + .get_method_id(class, "onContentReady", "()V") + .expect("onContentReady must exist"), }); } } diff --git a/src/lib.rs b/src/lib.rs index e7092fff..5d0745a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -76,6 +76,10 @@ impl PollRequester for EventSender { } } +// Static flag to track whether we've notified Java that content is ready +static CONTENT_READY_NOTIFIED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + #[tokio::main] async fn run(app: AndroidApp) { let mut last_frame_time = Instant::now(); @@ -265,8 +269,24 @@ async fn run(app: AndroidApp) { player_lock.mutate_with_update_context(|context| { context.set_root_movie(movie); }); + // Reset notification flag when new SWF is loaded + CONTENT_READY_NOTIFIED.store(false, std::sync::atomic::Ordering::Relaxed); + // Notify Java that content is loaded and ready + let (jvm, activity) = get_jvm().unwrap(); + let mut env = jvm.attach_current_thread().unwrap(); + JavaInterface::on_content_ready(&mut env, &activity); + log::info!("Notified Java that SWF content is ready"); } else { - player_lock.fetch_root_movie(url, Vec::new(), Box::new(|_| {})) + player_lock.fetch_root_movie(url, Vec::new(), Box::new(|_| { + // Notify Java that content is loaded and ready when fetched + let (_jvm, _activity) = get_jvm().unwrap(); + if let Ok((jvm, activity)) = get_jvm() { + if let Ok(mut env) = jvm.attach_current_thread() { + JavaInterface::on_content_ready(&mut env, &activity); + log::info!("Notified Java that fetched SWF content is ready"); + } + } + })) } player_lock.set_is_playing(true); // Desktop player will auto-play. @@ -458,6 +478,25 @@ async fn run(app: AndroidApp) { .downcast_mut::() .unwrap(); audio.recreate_stream_if_needed(); + + // Check if content is ready based on player state + if player.is_playing() { + // Check if we need to notify Java that content is ready + let already_notified = + CONTENT_READY_NOTIFIED.load(std::sync::atomic::Ordering::Relaxed); + + if !already_notified { + log::info!("Content appears to be ready (playing), notifying Java"); + if let Ok((jvm, activity)) = get_jvm() { + if let Ok(mut env) = jvm.attach_current_thread() { + JavaInterface::on_content_ready(&mut env, &activity); + log::info!("Notified Java that content is ready"); + CONTENT_READY_NOTIFIED + .store(true, std::sync::atomic::Ordering::Relaxed); + } + } + } + } } } else { next_frame_time = None;