feat(android): implement Project Thor dual-screen dashboard

- Add ThorPresentation.kt for secondary display performance dashboard
- Create thor_presentation.xml with real-time stats layout
- Modify EmulationActivity to auto-detect secondary displays
- Display FPS, speed, frame time, resolution, and shader status
- Color-coded FPS indicator (green/yellow/orange/red)
- Add 12 new string resources for Thor dashboard UI
- Keep main screen immersive while bottom screen shows stats

Signed-off-by: Zephyron <zephyron@citron-emu.org>
This commit is contained in:
Zephyron
2026-02-04 19:45:45 +10:00
parent a2bffc8994
commit eb8f6513cf
4 changed files with 599 additions and 0 deletions

View File

@@ -53,6 +53,7 @@ import org.citron.citron_emu.utils.NativeConfig
import org.citron.citron_emu.utils.NfcReader
import org.citron.citron_emu.utils.ParamPackage
import org.citron.citron_emu.utils.ThemeHelper
import org.citron.citron_emu.features.thor.ThorPresentation
import java.text.NumberFormat
import kotlin.math.roundToInt
@@ -72,6 +73,9 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
private val actionMute = "ACTION_EMULATOR_MUTE"
private val actionUnmute = "ACTION_EMULATOR_UNMUTE"
// PROJECT THOR: Dual Screen Dashboard for AYN Thor
private var thorPresentation: ThorPresentation? = null
private val emulationViewModel: EmulationViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
@@ -177,12 +181,67 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
InputHandler.updateControllerData()
buildPictureInPictureParams()
// PROJECT THOR: Detect secondary display and show performance dashboard
// This keeps the main screen immersive while showing stats on the bottom display
initThorPresentation()
}
/**
* PROJECT THOR: Initialize the dual-screen performance dashboard
* Automatically detects secondary displays (like AYN Thor's bottom screen)
* and shows a live performance dashboard with FPS, speed, and settings info.
*/
private fun initThorPresentation() {
if (thorPresentation == null && ThorPresentation.hasSecondaryDisplay(this)) {
try {
thorPresentation = ThorPresentation.createIfAvailable(this)
thorPresentation?.let { presentation ->
presentation.show()
presentation.startUpdates()
Toast.makeText(
this,
getString(R.string.thor_secondary_display_detected),
Toast.LENGTH_SHORT
).show()
Log.debug("[Thor] Secondary display detected - Dashboard enabled")
}
} catch (e: Exception) {
Log.error("[Thor] Failed to initialize presentation: ${e.message}")
thorPresentation = null
}
} else {
// Resume updates if presentation already exists
thorPresentation?.startUpdates()
}
}
/**
* PROJECT THOR: Clean up the dual-screen presentation
*/
private fun cleanupThorPresentation() {
thorPresentation?.let { presentation ->
presentation.stopUpdates()
if (presentation.isShowing) {
presentation.dismiss()
}
}
thorPresentation = null
}
override fun onPause() {
super.onPause()
nfcReader.stopScanning()
stopMotionSensorListener()
// PROJECT THOR: Stop dashboard updates when paused
thorPresentation?.stopUpdates()
}
override fun onDestroy() {
// PROJECT THOR: Clean up dual-screen presentation on destroy
cleanupThorPresentation()
super.onDestroy()
}
override fun onUserLeaveHint() {

View File

@@ -0,0 +1,225 @@
// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.citron.citron_emu.features.thor
import android.app.Presentation
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.Display
import android.view.View
import android.widget.TextView
import org.citron.citron_emu.NativeLibrary
import org.citron.citron_emu.R
import org.citron.citron_emu.features.settings.model.BooleanSetting
import org.citron.citron_emu.features.settings.model.IntSetting
/**
* PROJECT THOR: Dual Screen Implementation for AYN Thor
*
* This Presentation class renders a Live Performance Dashboard on the Thor's
* secondary bottom display, keeping the main 1080p screen completely immersive.
*
* Features:
* - Real-time FPS, Emulation Speed %, and Frame Time
* - Live settings display (Resolution, Docked State, CPU Accuracy)
* - Shader compilation indicator
*/
class ThorPresentation(
context: Context,
display: Display
) : Presentation(context, display) {
private lateinit var fpsText: TextView
private lateinit var speedText: TextView
private lateinit var frameTimeText: TextView
private lateinit var resolutionText: TextView
private lateinit var dockedText: TextView
private lateinit var cpuAccuracyText: TextView
private lateinit var shaderText: TextView
private lateinit var gpuDriverText: TextView
private lateinit var astcModeText: TextView
private val handler = Handler(Looper.getMainLooper())
private var isRunning = false
// Poll interval for performance stats (500ms as specified in report)
private val pollInterval = 500L
private val updateRunnable = object : Runnable {
override fun run() {
if (isRunning) {
updatePerformanceStats()
handler.postDelayed(this, pollInterval)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.thor_presentation)
initializeViews()
updateStaticSettings()
}
private fun initializeViews() {
fpsText = findViewById(R.id.thor_fps)
speedText = findViewById(R.id.thor_speed)
frameTimeText = findViewById(R.id.thor_frame_time)
resolutionText = findViewById(R.id.thor_resolution)
dockedText = findViewById(R.id.thor_docked)
cpuAccuracyText = findViewById(R.id.thor_cpu_accuracy)
shaderText = findViewById(R.id.thor_shader_status)
gpuDriverText = findViewById(R.id.thor_gpu_driver)
astcModeText = findViewById(R.id.thor_astc_mode)
}
/**
* Update static settings that don't change during gameplay
*/
private fun updateStaticSettings() {
// Resolution scaling - based on arrays.xml rendererResolutionValues
val resolutionIndex = IntSetting.RENDERER_RESOLUTION.getInt()
val resolutionString = when (resolutionIndex) {
-1 -> "0.25X (180p)"
0 -> "0.5X (360p)"
1 -> "0.75X (540p)"
2 -> "1X (720p)"
11 -> "1.25X (900p)"
3 -> "1.5X (1080p)" // PROJECT THOR: Optimal for AYN Thor
12 -> "1.75X (1260p)"
4 -> "2X (1440p)"
5 -> "3X (2160p)"
6 -> "4X (2880p)"
7 -> "5X"
8 -> "6X"
9 -> "7X"
10 -> "8X"
else -> "${resolutionIndex}X"
}
resolutionText.text = resolutionString
// Docked mode
val isDocked = BooleanSetting.DOCKED_MODE.getBoolean()
dockedText.text = if (isDocked) "Docked" else "Handheld"
// CPU Accuracy
val cpuAccuracy = IntSetting.CPU_ACCURACY.getInt()
val cpuAccuracyString = when (cpuAccuracy) {
0 -> "Auto"
1 -> "Accurate"
2 -> "Unsafe"
3 -> "Paranoid"
else -> "Unknown"
}
cpuAccuracyText.text = cpuAccuracyString
// GPU Driver (if custom driver is loaded)
val gpuDriver = NativeLibrary.getGpuDriver()
gpuDriverText.text = if (gpuDriver.isNotEmpty()) gpuDriver else "System Driver"
// ASTC Decode Mode
val astcMode = IntSetting.ANDROID_ASTC_MODE.getInt()
val astcModeString = when (astcMode) {
0 -> "Auto"
1 -> "Native"
2 -> "Decompress"
else -> "Auto"
}
astcModeText.text = astcModeString
}
/**
* Update real-time performance statistics
* Called every 500ms while emulation is running
*/
private fun updatePerformanceStats() {
try {
val perfStats = NativeLibrary.getPerfStats()
if (perfStats != null && perfStats.size >= 4) {
// perfStats format: [fps, emulationSpeed, frameTime, fifoPercentage]
val fps = perfStats[0]
val speed = perfStats[1]
val frameTime = perfStats[2]
fpsText.text = String.format("%.1f FPS", fps)
speedText.text = String.format("%.0f%%", speed * 100)
frameTimeText.text = String.format("%.2f ms", frameTime * 1000)
// Color code FPS based on performance
val fpsColor = when {
fps >= 58 -> 0xFF00FF00.toInt() // Green - excellent
fps >= 45 -> 0xFFFFFF00.toInt() // Yellow - acceptable
fps >= 30 -> 0xFFFF8800.toInt() // Orange - playable
else -> 0xFFFF0000.toInt() // Red - poor
}
fpsText.setTextColor(fpsColor)
}
// Shader compilation status
val shadersBuilding = NativeLibrary.getShadersBuilding()
if (shadersBuilding > 0) {
shaderText.text = context.getString(R.string.thor_shaders_building) + " ($shadersBuilding)"
shaderText.visibility = View.VISIBLE
shaderText.setTextColor(0xFFFFAA00.toInt()) // Orange
} else {
shaderText.visibility = View.GONE
}
} catch (e: Exception) {
// Handle any native library exceptions gracefully
fpsText.text = "-- FPS"
speedText.text = "--%"
frameTimeText.text = "-- ms"
}
}
fun startUpdates() {
isRunning = true
handler.post(updateRunnable)
}
fun stopUpdates() {
isRunning = false
handler.removeCallbacks(updateRunnable)
}
override fun onStop() {
super.onStop()
stopUpdates()
}
companion object {
private const val TAG = "ThorPresentation"
/**
* Check if the device has a secondary display (like AYN Thor)
*/
fun hasSecondaryDisplay(context: Context): Boolean {
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val displays = displayManager.displays
return displays.size > 1
}
/**
* Get the secondary display for Thor presentation
*/
fun getSecondaryDisplay(context: Context): Display? {
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val displays = displayManager.displays
// Find the first non-default display
return displays.firstOrNull { it.displayId != Display.DEFAULT_DISPLAY }
}
/**
* Create and show the Thor presentation on the secondary display
*/
fun createIfAvailable(context: Context): ThorPresentation? {
val secondaryDisplay = getSecondaryDisplay(context) ?: return null
return ThorPresentation(context, secondaryDisplay)
}
}
}

View File

@@ -0,0 +1,301 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- PROJECT THOR: Dual Screen Dashboard Layout for AYN Thor -->
<!-- This layout is displayed on the secondary bottom screen during emulation -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#CC000000"
android:gravity="center"
android:orientation="vertical"
android:padding="16dp">
<!-- Title Bar -->
<TextView
android:id="@+id/thor_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/thor_dashboard_title"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginBottom="12dp" />
<!-- Main Stats Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginBottom="8dp">
<!-- FPS -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/thor_fps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="60.0 FPS"
android:textColor="#00FF00"
android:textSize="24sp"
android:textStyle="bold"
tools:text="60.0 FPS" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/thor_fps_label"
android:textColor="#888888"
android:textSize="10sp" />
</LinearLayout>
<!-- Divider -->
<View
android:layout_width="1dp"
android:layout_height="40dp"
android:background="#444444" />
<!-- Speed -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/thor_speed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="100%"
android:textColor="#FFFFFF"
android:textSize="24sp"
android:textStyle="bold"
tools:text="100%" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/thor_speed_label"
android:textColor="#888888"
android:textSize="10sp" />
</LinearLayout>
<!-- Divider -->
<View
android:layout_width="1dp"
android:layout_height="40dp"
android:background="#444444" />
<!-- Frame Time -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/thor_frame_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="16.67 ms"
android:textColor="#FFFFFF"
android:textSize="24sp"
android:textStyle="bold"
tools:text="16.67 ms" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/thor_frame_time_label"
android:textColor="#888888"
android:textSize="10sp" />
</LinearLayout>
</LinearLayout>
<!-- Shader Compilation Status (shown only when compiling) -->
<TextView
android:id="@+id/thor_shader_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/thor_shaders_building"
android:textColor="#FFAA00"
android:textSize="12sp"
android:textStyle="italic"
android:visibility="gone"
android:layout_marginBottom="8dp"
tools:visibility="visible" />
<!-- Divider Line -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#444444"
android:layout_marginVertical="8dp" />
<!-- Settings Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<!-- Resolution -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/thor_resolution"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1.5X"
android:textColor="#00AAFF"
android:textSize="16sp"
android:textStyle="bold"
tools:text="1.5X (1080p)" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/thor_resolution_label"
android:textColor="#888888"
android:textSize="10sp" />
</LinearLayout>
<!-- Docked State -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/thor_docked"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Docked"
android:textColor="#00AAFF"
android:textSize="16sp"
android:textStyle="bold"
tools:text="Docked" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/thor_mode_label"
android:textColor="#888888"
android:textSize="10sp" />
</LinearLayout>
<!-- CPU Accuracy -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/thor_cpu_accuracy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Auto"
android:textColor="#00AAFF"
android:textSize="16sp"
android:textStyle="bold"
tools:text="Auto" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/thor_cpu_label"
android:textColor="#888888"
android:textSize="10sp" />
</LinearLayout>
</LinearLayout>
<!-- Second Settings Row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="8dp">
<!-- GPU Driver -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/thor_gpu_driver"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Turnip"
android:textColor="#AA88FF"
android:textSize="14sp"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end"
tools:text="Turnip v24.1" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/thor_gpu_driver_label"
android:textColor="#888888"
android:textSize="10sp" />
</LinearLayout>
<!-- ASTC Mode -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/thor_astc_mode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Native"
android:textColor="#AA88FF"
android:textSize="14sp"
android:textStyle="bold"
tools:text="Native" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/thor_astc_label"
android:textColor="#888888"
android:textSize="10sp" />
</LinearLayout>
</LinearLayout>
<!-- Citron Branding -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/thor_branding"
android:textColor="#444444"
android:textSize="10sp"
android:layout_marginTop="12dp" />
</LinearLayout>

View File

@@ -1325,4 +1325,18 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
<string name="crt_mask_aperture">Aperture Grille</string>
<string name="crt_mask_shadow">Shadow Mask</string>
<!-- PROJECT THOR: Dual Screen Dashboard Strings -->
<string name="thor_dashboard_title">CITRON PERFORMANCE DASHBOARD</string>
<string name="thor_fps_label">Frame Rate</string>
<string name="thor_speed_label">Speed</string>
<string name="thor_frame_time_label">Frame Time</string>
<string name="thor_resolution_label">Resolution</string>
<string name="thor_mode_label">Mode</string>
<string name="thor_cpu_label">CPU</string>
<string name="thor_gpu_driver_label">GPU Driver</string>
<string name="thor_astc_label">ASTC</string>
<string name="thor_shaders_building">Compiling Shaders…</string>
<string name="thor_branding">Powered by Citron</string>
<string name="thor_secondary_display_detected">Secondary display detected - Dashboard enabled</string>
</resources>