mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
refactor(ui): move the session loading screen out of SessionOverlay
This commit is contained in:
1 parent
63c58e94ce
commit
75db49bb88
2 files changed
+274
-222
No files matched your search
@@ -0,0 +1,268 @@
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
// The session's loading screen, its paused screen and the HUD text style.
|
||||
|
||||
@Composable
|
||||
fun HudText(text: String) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
|
||||
Text(
|
||||
text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.background(Color(0x8C000000))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One line of the loading screen's checklist; [detail] is what it says while it is the one running. */
|
||||
private class LoadStage(val label: String, val detail: String)
|
||||
|
||||
private fun loadStages(steam: Boolean): List<LoadStage> = if (steam) listOf(
|
||||
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
|
||||
LoadStage("Session", "Starting the display and audio"),
|
||||
LoadStage("Steam client", "Checking the Steam client and its compatibility tools"),
|
||||
LoadStage("Opening Steam", "Waiting for Steam's first frame"),
|
||||
) else listOf(
|
||||
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
|
||||
LoadStage("Desktop", "Getting the desktop ready"),
|
||||
LoadStage("Opening", "Waiting for the first frame"),
|
||||
)
|
||||
|
||||
/**
|
||||
* The checklist stage a loading line belongs to, -1 when it says nothing about the stage. The lines
|
||||
* are the session script's "== STEP" milestones and the installer's own progress; they arrive in
|
||||
* order, and the screen never steps back.
|
||||
*/
|
||||
private fun stageOf(step: String, steam: Boolean): Int {
|
||||
val t = step.lowercase()
|
||||
return when {
|
||||
"linux runtime" in t -> 0
|
||||
"starting the session" in t -> 1
|
||||
!steam && "desktop" in t -> 1
|
||||
steam && "starting the steam client" in t -> 3
|
||||
steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> 2
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
/** Download and install lines are worth reading as they are; the script's own notes are not. */
|
||||
private fun readableStep(step: String): Boolean {
|
||||
val t = step.lowercase()
|
||||
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingOverlay(
|
||||
step: String, percent: Int, elapsed: String, hint: String, ended: Boolean,
|
||||
title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val stages = remember(steam) { loadStages(steam) }
|
||||
var reached by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(step, steam) {
|
||||
val at = stageOf(step, steam)
|
||||
if (at > reached) reached = at
|
||||
}
|
||||
val active = reached.coerceAtMost(stages.lastIndex)
|
||||
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()) }
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||
) {
|
||||
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,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/** One segment per stage: done ones filled, the current one filling (or running, with no percent). */
|
||||
@Composable
|
||||
private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifier = Modifier) {
|
||||
val pal = LocalPalette.current
|
||||
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" },
|
||||
) {
|
||||
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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
|
||||
/** A session paused in the background, ready to pick up where it left off. */
|
||||
@Composable
|
||||
fun SessionPausedOverlay(title: String = "Steam is paused", onResume: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val resumeFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
runCatching { resumeFocus.requestFocus() }
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(pal.background.copy(alpha = 0.94f))
|
||||
.clickable(interactionSource = interactionSource, indication = null) {},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.widthIn(max = 440.dp).padding(32.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(bottom = 6.dp).size(64.dp).border(2.dp, pal.line2, CircleShape)) {
|
||||
Icon(Icons.Filled.Pause, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, textAlign = TextAlign.Center)
|
||||
PrimaryButton(
|
||||
"Resume",
|
||||
modifier = Modifier.padding(top = 14.dp).focusRequester(resumeFocus).controllerConfirm(onClick = onResume),
|
||||
onClick = onResume,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,15 @@
|
||||
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
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.outlined.Menu
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.material.icons.outlined.PowerSettingsNew
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
@@ -54,10 +38,8 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -70,12 +52,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.HomeApp
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
@@ -92,7 +70,6 @@ import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -121,8 +98,10 @@ import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
private val drawerPageTitles = listOf("Display", "Controls", "Components", "Settings")
|
||||
|
||||
/** One icon per drawer page, in page order (QAM-style tabs). */
|
||||
private val drawerPageIcons = listOf(Icons.Outlined.DesktopWindows, Icons.Outlined.SportsEsports, Icons.Outlined.Layers, Icons.Outlined.Settings)
|
||||
|
||||
private val drawerPageEntries = listOf("hud", "touch", "cmp-proton", "suspend")
|
||||
|
||||
private class DrawerFocus {
|
||||
@@ -145,205 +124,6 @@ private class DrawerFocus {
|
||||
fun forget(page: Int) { last[page] = null }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HudText(text: String) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
|
||||
Text(
|
||||
text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.background(Color(0x8C000000))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One line of the loading screen's checklist; [detail] is what it says while it is the one running. */
|
||||
private class LoadStage(val label: String, val detail: String)
|
||||
|
||||
private fun loadStages(steam: Boolean): List<LoadStage> = if (steam) listOf(
|
||||
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
|
||||
LoadStage("Session", "Starting the display and audio"),
|
||||
LoadStage("Steam client", "Checking the Steam client and its compatibility tools"),
|
||||
LoadStage("Opening Steam", "Waiting for Steam's first frame"),
|
||||
) else listOf(
|
||||
LoadStage("Linux runtime", "Getting the Linux runtime ready"),
|
||||
LoadStage("Desktop", "Getting the desktop ready"),
|
||||
LoadStage("Opening", "Waiting for the first frame"),
|
||||
)
|
||||
|
||||
/**
|
||||
* The checklist stage a loading line belongs to, -1 when it says nothing about the stage. The lines
|
||||
* are the session script's "== STEP" milestones and the installer's own progress; they arrive in
|
||||
* order, and the screen never steps back.
|
||||
*/
|
||||
private fun stageOf(step: String, steam: Boolean): Int {
|
||||
val t = step.lowercase()
|
||||
return when {
|
||||
"linux runtime" in t -> 0
|
||||
"starting the session" in t -> 1
|
||||
!steam && "desktop" in t -> 1
|
||||
steam && "starting the steam client" in t -> 3
|
||||
steam && ("steam" in t || "client" in t || "proton" in t || "library" in t || "compatibility" in t) -> 2
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
/** Download and install lines are worth reading as they are; the script's own notes are not. */
|
||||
private fun readableStep(step: String): Boolean {
|
||||
val t = step.lowercase()
|
||||
return t.startsWith("download") || t.startsWith("checking the linux") || t.startsWith("unpacking") || t.startsWith("installing")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingOverlay(
|
||||
step: String, percent: Int, elapsed: String, hint: String, ended: Boolean,
|
||||
title: String = "Starting Steam", steam: Boolean = true, onCancel: (() -> Unit)? = null,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val stages = remember(steam) { loadStages(steam) }
|
||||
var reached by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(step, steam) {
|
||||
val at = stageOf(step, steam)
|
||||
if (at > reached) reached = at
|
||||
}
|
||||
val active = reached.coerceAtMost(stages.lastIndex)
|
||||
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()) }
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
|
||||
) {
|
||||
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,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/** One segment per stage: done ones filled, the current one filling (or running, with no percent). */
|
||||
@Composable
|
||||
private fun LoadSegments(count: Int, active: Int, percent: Int, modifier: Modifier = Modifier) {
|
||||
val pal = LocalPalette.current
|
||||
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" },
|
||||
) {
|
||||
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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
|
||||
/** A session paused in the background, ready to pick up where it left off. */
|
||||
@Composable
|
||||
fun SessionPausedOverlay(title: String = "Steam is paused", onResume: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val resumeFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
runCatching { resumeFocus.requestFocus() }
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(pal.background.copy(alpha = 0.94f))
|
||||
.clickable(interactionSource = interactionSource, indication = null) {},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.widthIn(max = 440.dp).padding(32.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(bottom = 6.dp).size(64.dp).border(2.dp, pal.line2, CircleShape)) {
|
||||
Icon(Icons.Filled.Pause, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, textAlign = TextAlign.Center)
|
||||
PrimaryButton(
|
||||
"Resume",
|
||||
modifier = Modifier.padding(top = 14.dp).focusRequester(resumeFocus).controllerConfirm(onClick = onResume),
|
||||
onClick = onResume,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the drawer shows and does. */
|
||||
class DrawerActions(
|
||||
val steam: Boolean,
|
||||
@@ -859,6 +639,7 @@ private fun DrawerPageTabs(page: Int, modifier: Modifier = Modifier, compact: Bo
|
||||
|
||||
/** Display, Controls, Components, Settings. */
|
||||
const val DRAWER_PAGES = 4
|
||||
|
||||
private const val DRAWER_PAGE_COMPONENTS = 2
|
||||
|
||||
/**
|
||||
@@ -987,6 +768,9 @@ private fun <T> DrawerStackedChoice(
|
||||
|
||||
/** The tab row's measure, from the approved mock: side tabs at half size, a little faded. */
|
||||
private const val DRAWER_TAB_SPACING_DP = 64f
|
||||
|
||||
private const val DRAWER_TAB_LEAN_DP = 12f
|
||||
|
||||
private const val DRAWER_TAB_SIDE_SCALE = 0.5f
|
||||
|
||||
private const val DRAWER_TAB_SIDE_ALPHA = 0.7f
|
||||
Reference in new issue
Block a user