feat(session): animate the logo on the loading screen

This commit is contained in:
xXJsonDeruloXx committed 2026-09-26 22:44:09 -04:00
1 parent 2b1ef0d3e6
commit 110f5e595d
4 files changed
+270 -109

No files matched your search

@@ -944,6 +944,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
drawerControllerActive = true
}
// While the session starts, B is the loading screen's Cancel, as its corner says.
if (fromController && event.keyCode == KeyEvent.KEYCODE_BUTTON_B && ::loading.isInitialized && loading.visible && !loading.ended && !drawerOpen) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
SessionService.stop(this)
finish()
}
return true
}
if (drawerOpen) {
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
@@ -1,5 +1,18 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.runtime.produceState
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import com.droiddeck.launcher.frontend.Library
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.offset
@@ -191,7 +204,6 @@ fun LoadingOverlay(
title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val stages = remember(steam) { loadStages(steam) }
var reached by remember { mutableStateOf(0) }
LaunchedEffect(step, steam) {
@@ -199,128 +211,100 @@ fun LoadingOverlay(
if (at > reached) reached = at
}
val active = reached.coerceAtMost(stages.lastIndex)
val stageRows: @Composable () -> Unit = {
stages.forEachIndexed { i, stage ->
StageRow(
label = stage.label,
state = when { i < active -> StageMark.DONE; i == active -> StageMark.ACTIVE; else -> StageMark.PENDING },
detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stage.detail,
percent = percent,
)
}
val detail = if (percent >= 0 || readableStep(step)) step.replaceFirstChar { it.uppercase() } else stages[active].detail
val context = LocalContext.current
// The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in.
val games by produceState(emptyList<Library.SteamGame>()) {
value = withContext(Dispatchers.IO) { runCatching { Library.steamGames(context).sortedByDescending { it.lastPlayed } }.getOrDefault(emptyList()) }
}
androidx.compose.foundation.layout.BoxWithConstraints(
Box(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
) {
// A 4:3, square or short screen has no room for the side-by-side layout: one column, the spinner
// beside the title, and the tip and Cancel along the bottom where they stay in view.
if (maxWidth < 600.dp || maxHeight < 400.dp) Column(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(48.dp)) {
if (!ended) CircularProgressIndicator(color = pal.signal, trackColor = pal.line, strokeWidth = 2.dp, modifier = Modifier.fillMaxSize())
Image(painterResource(R.drawable.logo), contentDescription = null, modifier = Modifier.size(32.dp))
}
Text(
if (ended) "The session ended" else title, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
)
if (!ended && elapsed.isNotEmpty()) Text(elapsed.substringBefore(' '), fontSize = 14.sp, color = colors.onSurfaceVariant)
}
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 12.dp)) {
if (ended) Text(step, color = colors.onSurfaceVariant, fontSize = 14.sp)
else stageRows()
}
if (!ended && (hint.isNotEmpty() || onCancel != null)) {
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
) {
Text(hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
if (onCancel != null) SecondaryButton("Cancel", compact = true, onClick = onCancel)
}
}
} else Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.weight(0.4f).fillMaxHeight()) {
if (!ended) CircularProgressIndicator(
color = pal.signal, trackColor = pal.line, strokeWidth = 2.dp, modifier = Modifier.size(150.dp),
)
Image(painterResource(R.drawable.logo), contentDescription = null, modifier = Modifier.size(84.dp))
}
// Centred when it fits, scrolling when a long failure message does not.
Box(contentAlignment = Alignment.CenterStart, modifier = Modifier.weight(0.6f).fillMaxHeight()) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
.padding(start = 8.dp, end = 48.dp, top = 28.dp, bottom = 28.dp),
) {
Text(
if (ended) "The session ended" else title,
color = colors.onBackground, fontSize = 28.sp, fontWeight = FontWeight.Bold,
CapsuleWall(games, driftMs = 65_000, modifier = Modifier.alpha(0.3f))
Box(
Modifier.fillMaxSize().drawBehind {
drawRect(
Brush.radialGradient(
0f to colors.background.copy(alpha = 0.94f),
0.6f to colors.background.copy(alpha = 0.72f),
1f to colors.background.copy(alpha = 0.55f),
center = Offset(size.width / 2f, size.height * 0.46f), radius = maxOf(size.width, size.height) * 0.55f,
),
)
if (ended) {
Text(step, color = colors.onSurfaceVariant, fontSize = 14.sp, modifier = Modifier.padding(top = 10.dp))
} else {
if (elapsed.isNotEmpty()) Text(elapsed, color = colors.onSurfaceVariant, fontSize = 13.sp, modifier = Modifier.padding(top = 2.dp))
Spacer(Modifier.height(16.dp))
stageRows()
if (hint.isNotEmpty()) Text(
hint, color = colors.onSurfaceVariant, fontSize = 14.sp,
modifier = Modifier.padding(top = 16.dp).widthIn(max = 480.dp)
.clip(RoundedCornerShape(12.dp)).border(1.dp, pal.line2, RoundedCornerShape(12.dp))
.padding(horizontal = 14.dp, vertical = 10.dp),
)
if (onCancel != null) Box(Modifier.padding(top = 16.dp)) { SecondaryButton("Cancel", onClick = onCancel) }
}
}
},
)
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.7f to Color.Transparent, 1f to colors.background.copy(alpha = 0.95f))))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(Alignment.Center).verticalScroll(rememberScrollState()).padding(horizontal = 32.dp, vertical = 24.dp),
) {
LogoThrobber(Modifier.width(if (ended) 88.dp else 110.dp), running = !ended)
Spacer(Modifier.height(30.dp))
Text(
if (ended) "The session ended" else title, fontSize = 20.sp, fontWeight = FontWeight.SemiBold,
color = colors.onBackground, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Text(
if (ended) step else detail, fontSize = 14.sp, lineHeight = 20.sp, color = colors.onSurfaceVariant, textAlign = TextAlign.Center,
maxLines = if (ended) 12 else 2, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp).widthIn(max = 520.dp),
)
if (!ended) LoadSegments(stages.size, active, percent, Modifier.padding(top = 18.dp))
}
}
if (!ended && hint.isNotEmpty()) Text(
hint, fontSize = 13.sp, lineHeight = 18.sp, color = colors.onSurfaceVariant.copy(alpha = 0.8f), textAlign = TextAlign.Center,
maxLines = 3, overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomCenter).padding(start = 120.dp, end = 120.dp, bottom = 28.dp),
)
if (!ended && onCancel != null) CancelHint(onCancel, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 16.dp))
}
}
private enum class StageMark { DONE, ACTIVE, PENDING }
/** One segment per stage: done ones filled, the current one filling (or running, with no percent). */
@Composable
private fun StageRow(label: String, state: StageMark, detail: String, percent: Int) {
val colors = MaterialTheme.colorScheme
private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifier = Modifier) {
val pal = LocalPalette.current
val shape = RoundedCornerShape(14.dp)
Column(
modifier = Modifier.widthIn(max = 520.dp).fillMaxWidth()
.then(
if (state == StageMark.ACTIVE) Modifier.padding(vertical = 4.dp).clip(shape).background(colors.surface)
.border(1.dp, pal.line, shape).padding(horizontal = 12.dp, vertical = 10.dp)
else Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
),
val track = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.12f)
val run = rememberInfiniteTransition(label = "segment")
.animateFloat(-0.4f, 1f, infiniteRepeatable(tween(1300, easing = FastOutSlowInEasing)), label = "run")
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier.semantics { contentDescription = "Step ${active + 1} of $count" },
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
when (state) {
StageMark.DONE -> Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp).clip(CircleShape).background(pal.good.copy(alpha = 0.16f))) {
Icon(Icons.Filled.Check, contentDescription = "Done", tint = pal.good, modifier = Modifier.size(15.dp))
}
StageMark.ACTIVE -> Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp).border(2.dp, pal.signal, CircleShape)) {
Box(Modifier.size(9.dp).clip(CircleShape).background(pal.signal))
}
StageMark.PENDING -> Box(Modifier.size(24.dp).border(2.dp, pal.line2, CircleShape))
}
Text(
label, fontSize = 16.sp, modifier = Modifier.weight(1f),
fontWeight = if (state == StageMark.ACTIVE) FontWeight.SemiBold else FontWeight.Normal,
color = if (state == StageMark.PENDING) colors.onSurfaceVariant else colors.onBackground,
repeat(count) { i ->
Box(
Modifier.width(44.dp).height(3.dp).clip(RoundedCornerShape(2.dp))
.background(if (i < active) pal.signal else track)
.drawBehind {
if (i != active) return@drawBehind
if (percent >= 0) drawRect(pal.signal, size = Size(size.width * percent.coerceIn(0, 100) / 100f, size.height))
else drawRect(pal.signal, topLeft = Offset(size.width * run.value, 0f), size = Size(size.width * 0.4f, size.height))
},
)
if (state == StageMark.ACTIVE && percent >= 0) Text("$percent%", fontSize = 14.sp, color = colors.onSurfaceVariant)
}
if (state == StageMark.ACTIVE) {
Text(
detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 36.dp, top = 6.dp),
)
val bar = Modifier.padding(start = 36.dp, top = 8.dp).fillMaxWidth().height(4.dp)
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = bar, color = pal.signal, trackColor = pal.line)
else LinearProgressIndicator(modifier = bar, color = pal.signal, trackColor = pal.line)
}
}
/** Cancel as the pad's B, in the corner; a tap works too. */
@Composable
private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = src.collectIsHoveredAsState().value || src.collectIsFocusedAsState().value
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(22.dp))
.background(if (hot) colors.onBackground.copy(alpha = 0.08f) else Color.Transparent)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onCancel)
.padding(horizontal = 12.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).background(colors.onBackground)) {
Text("B", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = colors.background)
}
Text("Cancel", fontSize = 13.sp, color = colors.onSurfaceVariant)
}
}
@@ -116,19 +116,23 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
* library draws the same wall in blank capsules, slower.
*/
@Composable
private fun CapsuleWall(games: List<Library.SteamGame>) {
internal fun CapsuleWall(
games: List<Library.SteamGame>,
driftMs: Int = if (games.isEmpty()) 75_000 else 40_000,
modifier: Modifier = Modifier,
) {
val capW = 112.dp
val capH = 168.dp
val gap = 14.dp
val transition = rememberInfiniteTransition(label = "wall")
val drift = transition.animateFloat(
0f, 1f,
infiniteRepeatable(tween(if (games.isEmpty()) 75_000 else 40_000, easing = LinearEasing)),
infiniteRepeatable(tween(driftMs, easing = LinearEasing)),
label = "drift",
)
// Animations off in the system settings: a still wall.
val still = Motion.scale == 0f
BoxWithConstraints(Modifier.fillMaxSize()) {
BoxWithConstraints(modifier.fillMaxSize()) {
val wallW = maxWidth * 1.3f
val wallH = maxHeight * 2.4f
val columns = ((wallW + gap) / (capW + gap)).toInt() + 1
@@ -0,0 +1,165 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.rotate
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* The DroidDeck mark as the loading indicator. Every loop the D's square half bends into an arc,
* the ring they make turns once around the ball, and the half straightens back into the D.
*/
@Composable
internal fun LogoThrobber(modifier: Modifier = Modifier, running: Boolean = true) {
val ink = MaterialTheme.colorScheme.onBackground
val ball = LocalPalette.current.signal
val loop = rememberInfiniteTransition(label = "throbber")
.animateFloat(0f, 1f, infiniteRepeatable(tween(LOOP_MS, easing = LinearEasing)), label = "loop")
// Animations off in the system settings, or nothing running: the plain D.
val still = !running || Motion.scale == 0f
val left = remember { Path() }
val right = remember { Path() }
Canvas(modifier.aspectRatio(LogoShape.WIDTH / LogoShape.HEIGHT)) {
val p = if (still) 0f else loop.value
val bend = when {
p < 0.10f -> 0f
p < 0.24f -> Settle.transform((p - 0.10f) / 0.14f)
p < 0.76f -> 1f
p < 0.90f -> 1f - Settle.transform((p - 0.76f) / 0.14f)
else -> 0f
}
val turn = if (p < 0.16f || p > 0.84f) 0f else Turn.transform((p - 0.16f) / 0.68f) * 360f
val ballScale = when {
p < 0.12f -> 1f
p < 0.30f -> 1f - 0.2f * Settle.transform((p - 0.12f) / 0.18f)
p < 0.70f -> 0.8f
p < 0.88f -> 0.8f + 0.2f * Settle.transform((p - 0.70f) / 0.18f)
else -> 1f
}
val s = size.width / LogoShape.WIDTH
fun px(x: Float, y: Float) = Offset((x - LogoShape.LEFT) * s, y * s)
val centre = px(LogoShape.CX, LogoShape.CY)
LogoShape.left(left, bend, ::px)
LogoShape.right(right, ::px)
rotate(turn, centre) {
drawPath(left, ink)
drawPath(right, ink)
}
drawCircle(ball, radius = LogoShape.BALL * s * ballScale, center = centre)
}
}
private const val LOOP_MS = 1900
private val Settle = CubicBezierEasing(0.5f, 0f, 0.3f, 1f)
private val Turn = CubicBezierEasing(0.65f, 0f, 0.35f, 1f)
/**
* The logo's geometry in its SVG's own units (viewBox -55.98 0 239.92 223.72): a D of a square
* left side and a round right side, cut by a ring around the ball and split top and bottom.
* The left half is sampled along rays from the centre so it can bend, point for point, into the
* mirror of the right half's arc.
*/
private object LogoShape {
const val LEFT = -55.98f
const val WIDTH = 239.92f
const val HEIGHT = 223.72f
const val CX = 63.98f
const val CY = 111.86f
const val BALL = 55.98f
private const val OUTER = 111.86f
private const val INNER = 71.81f
private const val SQUARE_X = -31.98f
private const val SPLIT_LEFT = 56.055f
private const val SPLIT_RIGHT = 71.885f
private class Pt(val x: Float, val y: Float)
private fun at(a: Double, r: Float) = Pt((CX + r * cos(a)).toFloat(), (CY + r * sin(a)).toFloat())
private fun angle(x: Float, y: Float) = atan2((y - CY).toDouble(), (x - CX).toDouble())
// Angles taken the long way round, through the left: from about -94 degrees down to -266.
private fun leftward(a: Double) = if (a < 0) a else a - 2 * Math.PI
private val rays: List<Double> = run {
val top = angle(SPLIT_LEFT, 0f)
val topCorner = angle(SQUARE_X, 0f)
val bottomCorner = leftward(angle(SQUARE_X, HEIGHT))
val bottom = leftward(angle(SPLIT_LEFT, HEIGHT))
// The corners are rays of their own, so the square half keeps sharp corners.
fun span(a: Double, b: Double, n: Int) = List(n) { i -> a + (b - a) * i / (n - 1) }
span(top, topCorner, 9).dropLast(1) + span(topCorner, bottomCorner, 25).dropLast(1) + span(bottomCorner, bottom, 9)
}
private val square: List<Pt> = rays.map { a ->
val c = cos(a)
val sn = sin(a)
val hits = buildList {
if (c < -1e-9) add((SQUARE_X - CX) / c)
if (sn < -1e-9) add((0f - CY) / sn)
if (sn > 1e-9) add((HEIGHT - CY) / sn)
}
val t = hits.minOrNull() ?: 0.0
Pt((CX + t * c).toFloat(), (CY + t * sn).toFloat())
}.toMutableList().also { it[0] = Pt(SPLIT_LEFT, 0f); it[it.lastIndex] = Pt(SPLIT_LEFT, HEIGHT) }
private val arc: List<Pt> = rays.map { at(it, OUTER) }.toMutableList().also {
val dy = sqrt(OUTER * OUTER - (SPLIT_LEFT - CX) * (SPLIT_LEFT - CX))
it[0] = Pt(SPLIT_LEFT, CY - dy)
it[it.lastIndex] = Pt(SPLIT_LEFT, CY + dy)
}
private val leftInner: List<Pt> = run {
val dx = (SPLIT_LEFT - CX).toDouble()
val dy = sqrt(INNER * INNER - dx * dx)
val from = atan2(dy, dx)
val to = atan2(-dy, dx) + 2 * Math.PI
List(rays.size) { i -> at(from + (to - from) * i / (rays.size - 1), INNER) }
}
private val rightHalf: List<Pt> = run {
val dx = (SPLIT_RIGHT - CX).toDouble()
val oy = sqrt(OUTER * OUTER - dx * dx)
val iy = sqrt(INNER * INNER - dx * dx)
val outerFrom = atan2(-oy, dx)
val outerTo = atan2(oy, dx)
val innerFrom = atan2(iy, dx)
val innerTo = atan2(-iy, dx)
List(40) { i -> at(outerFrom + (outerTo - outerFrom) * i / 39, OUTER) } +
List(40) { i -> at(innerFrom + (innerTo - innerFrom) * i / 39, INNER) }
}
/** The left half, [bend] of the way from the square D side (0) to the arc (1). */
fun left(path: Path, bend: Float, px: (Float, Float) -> Offset) {
path.reset()
square.forEachIndexed { i, q ->
val r = arc[i]
val o = px(q.x + (r.x - q.x) * bend, q.y + (r.y - q.y) * bend)
if (i == 0) path.moveTo(o.x, o.y) else path.lineTo(o.x, o.y)
}
for (q in leftInner) px(q.x, q.y).let { path.lineTo(it.x, it.y) }
path.close()
}
fun right(path: Path, px: (Float, Float) -> Offset) {
path.reset()
rightHalf.forEachIndexed { i, q ->
val o = px(q.x, q.y)
if (i == 0) path.moveTo(o.x, o.y) else path.lineTo(o.x, o.y)
}
path.close()
}
}