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:
The412Banner committed 2026-09-19 14:45:22 -04:00
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>
+11
View File
@@ -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>