mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
A loading panel that proves the session is moving
Between Play and the client's first frame there was a line of text in a corner of a black screen, for minutes on a first run. The panel in the middle now shows the runtime's milestone, the client's own download percentage lifted out of its log (its bootstrap prints "Downloading update (a of b KB)", which the milestones never reflected), a clock, and a rotating hint about what is happening. It reads the log's tail only, twice a second; a session that has already drawn a frame does not get the panel again.
This commit is contained in:
1 parent
b27a4f5b31
commit
ebae834e36
6 files changed
+257
-35
No files matched your search
@@ -15,13 +15,13 @@ import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import com.steamdeck.launcher.core.FileUtils
|
||||
import com.steamdeck.launcher.gpu.TurnipDriver
|
||||
import com.steamdeck.launcher.input.EvdevKeys
|
||||
import com.steamdeck.launcher.input.OnScreenControls
|
||||
import com.steamdeck.launcher.input.PadBridge
|
||||
import com.steamdeck.launcher.runtime.LinuxRuntime
|
||||
import com.steamdeck.launcher.session.LoadingPanel
|
||||
import com.steamdeck.launcher.session.SessionService
|
||||
import com.steamdeck.launcher.session.SessionState
|
||||
import com.steamdeck.launcher.wayland.CompositorHost
|
||||
@@ -40,7 +40,7 @@ import java.io.File
|
||||
*/
|
||||
class SessionActivity : Activity(), SurfaceHolder.Callback {
|
||||
private lateinit var surfaceView: SurfaceView
|
||||
private lateinit var statusView: TextView
|
||||
private lateinit var loading: LoadingPanel
|
||||
private var padBridge: PadBridge? = null
|
||||
private var onScreenControls: OnScreenControls? = null
|
||||
private var watching = true
|
||||
@@ -72,23 +72,22 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
|
||||
surfaceView.holder.addCallback(this)
|
||||
root.addView(surfaceView)
|
||||
// Until the client's first frame arrives there is nothing on screen for a minute or more
|
||||
// on a first run, so the session's own milestones are shown instead of a black panel.
|
||||
statusView = TextView(this).apply {
|
||||
setPadding(48, 48, 48, 48)
|
||||
setTextColor(0xFFDDDDDD.toInt())
|
||||
textSize = 14f
|
||||
text = getString(R.string.session_starting)
|
||||
// A session already running is past its milestones; do not cover its picture.
|
||||
visibility = if (SessionState.running) View.GONE else View.VISIBLE
|
||||
}
|
||||
root.addView(statusView)
|
||||
// on a first run, so the session's own progress is shown instead of a black panel.
|
||||
val panel = layoutInflater.inflate(R.layout.session_loading, root, false)
|
||||
root.addView(panel)
|
||||
loading = LoadingPanel(panel)
|
||||
// A session that has already drawn is past its milestones; do not cover its picture.
|
||||
if (SessionState.running && SessionState.firstFrameSeen) loading.hide() else loading.show()
|
||||
setContentView(root)
|
||||
|
||||
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
|
||||
padBridge = bridge
|
||||
onScreenControls = OnScreenControls(this, bridge).also { root.addView(it, 1) }
|
||||
updateOnScreenControls()
|
||||
WaylandCompositor.setFirstFrameListener { runOnUiThread { statusView.visibility = View.GONE } }
|
||||
WaylandCompositor.setFirstFrameListener {
|
||||
SessionState.firstFrameSeen = true
|
||||
runOnUiThread { loading.hide() }
|
||||
}
|
||||
SessionState.endListener = { status -> onSessionEnded(status) }
|
||||
watchSessionLog()
|
||||
}
|
||||
@@ -161,37 +160,25 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
|
||||
|
||||
// ── Session state ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Mirrors the session script's "== STEP" milestones onto the screen while it starts. */
|
||||
/** Keeps the loading panel current from the session log while it is showing. */
|
||||
private fun watchSessionLog() {
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
Thread({
|
||||
var shown = ""
|
||||
while (watching) {
|
||||
try {
|
||||
Thread.sleep(500)
|
||||
val log = SessionState.logFile ?: continue
|
||||
val text = FileUtils.readString(log) ?: continue
|
||||
val step = text.lineSequence().lastOrNull { it.startsWith("== STEP") } ?: continue
|
||||
val message = step.substringAfter("== STEP ").substringAfter(' ')
|
||||
if (message != shown) {
|
||||
shown = message
|
||||
handler.post { if (statusView.visibility == View.VISIBLE) statusView.text = message }
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
return@Thread
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
val poll = object : Runnable {
|
||||
override fun run() {
|
||||
if (!watching) return
|
||||
if (loading.isVisible) loading.update(SessionState.logFile)
|
||||
handler.postDelayed(this, 500)
|
||||
}
|
||||
}, "session-log").start()
|
||||
}
|
||||
handler.post(poll)
|
||||
}
|
||||
|
||||
private fun onSessionEnded(status: Int) {
|
||||
runOnUiThread {
|
||||
if (isFinishing || isDestroyed) return@runOnUiThread
|
||||
if (status != 0) {
|
||||
statusView.visibility = View.VISIBLE
|
||||
statusView.text = getString(R.string.session_ended, status,
|
||||
SessionState.logFile?.path ?: "-")
|
||||
loading.showEnded(getString(R.string.session_ended, status,
|
||||
SessionState.logFile?.path ?: "-"))
|
||||
// A moment on screen, so a failure is readable rather than a flash of black.
|
||||
Handler(Looper.getMainLooper()).postDelayed({ finish() }, 4000)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.steamdeck.launcher.session
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import com.steamdeck.launcher.R
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* The loading panel's brain: what to say, read out of the session log, and a clock so a user can
|
||||
* see that time is passing even when the log is quiet. The runtime script marks its milestones
|
||||
* with "== STEP"; the client's own bootstrap does not, but it does print its download progress,
|
||||
* and lifting that out is the difference between "starting the Steam client" for three minutes
|
||||
* and a percentage that moves.
|
||||
*/
|
||||
class LoadingPanel(private val root: View) {
|
||||
private val step = root.findViewById<TextView>(R.id.loading_step)
|
||||
private val progress = root.findViewById<ProgressBar>(R.id.loading_progress)
|
||||
private val elapsed = root.findViewById<TextView>(R.id.loading_elapsed)
|
||||
private val hint = root.findViewById<TextView>(R.id.loading_hint)
|
||||
private val hints = root.resources.getStringArray(R.array.loading_hints)
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val startedAt = SystemClock.elapsedRealtime()
|
||||
private var ticking = false
|
||||
|
||||
private val tick = object : Runnable {
|
||||
override fun run() {
|
||||
if (!ticking) return
|
||||
val seconds = (SystemClock.elapsedRealtime() - startedAt) / 1000
|
||||
elapsed.text = root.context.getString(R.string.loading_elapsed,
|
||||
String.format(java.util.Locale.US, "%d:%02d", seconds / 60, seconds % 60))
|
||||
// A new hint every eight seconds; the first one is the one that matters most.
|
||||
hint.text = hints[((seconds / 8) % hints.size).toInt()]
|
||||
handler.postDelayed(this, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
val isVisible: Boolean get() = root.visibility == View.VISIBLE
|
||||
|
||||
fun show() {
|
||||
root.visibility = View.VISIBLE
|
||||
if (!ticking) {
|
||||
ticking = true
|
||||
handler.post(tick)
|
||||
}
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
ticking = false
|
||||
root.visibility = View.GONE
|
||||
}
|
||||
|
||||
/** The session ended without a picture: say so where the milestones were. */
|
||||
fun showEnded(message: String) {
|
||||
show()
|
||||
ticking = false
|
||||
step.text = message
|
||||
progress.visibility = View.INVISIBLE
|
||||
elapsed.text = ""
|
||||
hint.text = ""
|
||||
}
|
||||
|
||||
/** Re-reads the end of the log and updates the line and the bar. Call from the UI thread. */
|
||||
fun update(log: File?) {
|
||||
val state = read(log) ?: return
|
||||
if (step.text != state.message) step.text = state.message
|
||||
if (state.percent < 0) {
|
||||
if (!progress.isIndeterminate) progress.isIndeterminate = true
|
||||
} else {
|
||||
if (progress.isIndeterminate) progress.isIndeterminate = false
|
||||
progress.progress = state.percent
|
||||
}
|
||||
}
|
||||
|
||||
private class State(val message: String, val percent: Int)
|
||||
|
||||
/**
|
||||
* Only the tail is read: the client alone writes megabytes an hour, and this runs twice a
|
||||
* second for as long as the panel is up.
|
||||
*/
|
||||
private fun read(log: File?): State? {
|
||||
if (log == null || !log.isFile) return null
|
||||
val text = try {
|
||||
RandomAccessFile(log, "r").use { file ->
|
||||
val length = file.length()
|
||||
val want = minOf(length, TAIL_BYTES)
|
||||
file.seek(length - want)
|
||||
val bytes = ByteArray(want.toInt())
|
||||
file.readFully(bytes)
|
||||
String(bytes, StandardCharsets.UTF_8)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
var stepAt = -1
|
||||
var stepText: String? = null
|
||||
var downloadAt = -1
|
||||
var downloadPercent = -1
|
||||
var clientDownloadAt = -1
|
||||
var clientDownload: String? = null
|
||||
var clientPercent = -1
|
||||
var offset = 0
|
||||
for (line in text.split('\n')) {
|
||||
val at = offset
|
||||
offset += line.length + 1
|
||||
when {
|
||||
line.startsWith("== STEP ") -> {
|
||||
stepAt = at
|
||||
stepText = line.substringAfter("== STEP ").substringAfter(' ')
|
||||
// "downloading Steam: bins_hardware_all (1/17)" — the installer's own count.
|
||||
val m = INSTALL_COUNT.find(line)
|
||||
if (m != null) {
|
||||
clientDownloadAt = at
|
||||
clientDownload = m.groupValues[1]
|
||||
clientPercent = m.groupValues[2].toInt() * 100 / maxOf(1, m.groupValues[3].toInt())
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// "Downloading update (350129 of 666167 KB)..." — the client's bootstrap.
|
||||
val m = UPDATE_PROGRESS.find(line)
|
||||
if (m != null) {
|
||||
downloadAt = at
|
||||
val done = m.groupValues[1].toLong()
|
||||
val total = maxOf(1L, m.groupValues[2].toLong())
|
||||
downloadPercent = (done * 100 / total).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val context = root.context
|
||||
return when {
|
||||
downloadAt > stepAt && downloadPercent >= 0 ->
|
||||
State(context.getString(R.string.loading_download, downloadPercent), downloadPercent)
|
||||
clientDownloadAt == stepAt && clientDownload != null ->
|
||||
State(context.getString(R.string.loading_client_download, clientDownload), clientPercent)
|
||||
stepText != null -> State(stepText, -1)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAIL_BYTES = 48L * 1024
|
||||
private val UPDATE_PROGRESS = Regex("""Downloading update \((\d+) of (\d+) KB\)""")
|
||||
private val INSTALL_COUNT = Regex("""downloading Steam: (\S+) \((\d+)/(\d+)\)""")
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ class SessionService : Service() {
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
if (SessionState.running) return START_NOT_STICKY
|
||||
SessionState.running = true
|
||||
SessionState.firstFrameSeen = false
|
||||
acquireLocks()
|
||||
Thread({ runSession() }, "session-start").start()
|
||||
// The activity or the notification stops us; the system must not resurrect a session whose
|
||||
|
||||
@@ -18,6 +18,10 @@ object SessionState {
|
||||
@Volatile
|
||||
var refreshHz: Float = 60f
|
||||
|
||||
/** The compositor has presented a frame, so the loading panel is behind us for this session. */
|
||||
@Volatile
|
||||
var firstFrameSeen = false
|
||||
|
||||
/** Where this session is writing its log, for the "it ended" message. */
|
||||
@Volatile
|
||||
var logFile: File? = null
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
What the user sees until the client draws its first frame, which on a first run is minutes:
|
||||
the runtime's own milestones, the client's download progress lifted out of its log, a clock
|
||||
that proves time is passing, and a hint about what all of this is. Sized small and centred so
|
||||
it reads the same on a phone and a tablet.
|
||||
-->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/loading"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:background="#FF000000"
|
||||
android:padding="32dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:contentDescription="@string/app_name"
|
||||
android:src="@drawable/logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/loading_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/loading_title"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="17sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/loading_step"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/session_starting"
|
||||
android:textColor="#FFB8C4D0"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/loading_progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="220dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:indeterminate="true"
|
||||
android:max="100" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/loading_elapsed"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textColor="#FF667788"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/loading_hint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:gravity="center"
|
||||
android:maxWidth="360dp"
|
||||
android:textColor="#FF667788"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
@@ -17,6 +17,17 @@
|
||||
<string name="catalog_unreachable">Could not reach the runtime catalog</string>
|
||||
<string name="install_failed">Install failed — nothing was changed</string>
|
||||
<string name="session_starting">Starting the session…</string>
|
||||
<string name="loading_title">Steam is loading</string>
|
||||
<string name="loading_elapsed">%1$s elapsed · still working</string>
|
||||
<string name="loading_download">Downloading the Steam client update · %1$d%%</string>
|
||||
<string name="loading_client_download">Downloading the Steam client · %1$s</string>
|
||||
<string-array name="loading_hints">
|
||||
<item>The first run downloads the Steam client from Valve and then its update — a few minutes on a good connection.</item>
|
||||
<item>The screen stays black until the client draws its first frame; nothing is stuck while this clock is moving.</item>
|
||||
<item>You can leave. Steam keeps running in the background and the notification brings you back.</item>
|
||||
<item>A controller shows up in Steam as an Xbox 360 pad. Without one, the on-screen pad appears.</item>
|
||||
<item>Games you install go through Valve\'s own ARM64 Proton; the client fetches it the first time it is needed.</item>
|
||||
</string-array>
|
||||
<string name="session_ended">The session ended (%1$d)\n%2$s</string>
|
||||
<string name="session_channel">Session</string>
|
||||
<string name="session_channel_description">Keeps Steam running while the app is in the background</string>
|
||||
|
||||
Reference in new issue
Block a user