Polish Requests transitions and shell hosts

This commit is contained in:
justinzeus 2026-11-08 01:56:06 +01:00
parent 74b737fb52
commit 2572da00fd
5 changed files with 466 additions and 39 deletions

View file

@ -1,14 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -16,6 +26,13 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.layout.layout
import kotlin.math.roundToInt
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
@ -29,6 +46,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import androidx.compose.runtime.mutableStateOf
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
@ -36,10 +54,19 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */
@Composable
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
val visible = shell.requestsVisible
val fade = rememberSheetFade(visible)
if (!visible && fade.value == 0f) return
val state = shell.requests
val data = state.snapshot
val current = RequestsPanelFrame(state.snapshot, state.focusedId, state.expanded, state.windowStart, state.windowSize,
state.recoveryFocused, state.ratingsVisible, shell.requestsExpansionInput)
val retained = remember { mutableStateOf(current) }
SideEffect { if (visible) retained.value = current }
val frame = if (visible) current else retained.value
val data = frame.data
val ratingsReader = LocalScreenRatings.current
LaunchedEffect(state, state.focusedId, data.signedIn, data.status) {
LaunchedEffect(visible, state, state.focusedId, data.signedIn, data.status) {
if (!visible) return@LaunchedEffect
if (ratingsReader == null) state.awaitFocusedRatings()
else if (data.signedIn) state.focused?.let { item ->
ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values ->
@ -50,8 +77,9 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
}
}
}
Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim"))
Column(modifier.fillMaxHeight().width(L.sheetWidth)
Box(modifier.fillMaxSize().sheetScrim { fade.value }.focusProperties { canFocus = false }
.clickable { if (visible) shell.closeRequests() }.testTag("coming-in-scrim"))
Column(modifier.sheetPanelFade { fade.value }.fillMaxHeight().width(L.sheetWidth)
.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
.drawWithCache {
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
@ -61,9 +89,11 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
onDrawBehind {
drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) }
}
}.pointerInput(Unit) {
}.focusProperties { canFocus = visible }
.then(if (visible) Modifier else Modifier.clearAndSetSemantics { })
.pointerInput(visible) {
awaitPointerEventScope {
while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() }
while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() }
}
}
.padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY)
@ -71,10 +101,10 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg)
when {
data.status == ComingInStatus.Loading && data.items.isEmpty() -> {
LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) {
LazyColumn(Modifier.skeletonPulse(), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() }
}
SheetButton("Close") { shell.closeRequests() }
SheetButton("Close", interactive = visible) { shell.closeRequests() }
}
data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> {
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
@ -86,34 +116,50 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
}
Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted)
}
SheetButton("Try again", primary = true) { shell.retryRequests() }
SheetButton("Try again", primary = true, interactive = visible) { shell.retryRequests() }
}
data.items.isEmpty() -> {
Column(Modifier.padding(top = T.emptyMargin), verticalArrangement = Arrangement.spacedBy(T.receiptGap)) {
Text("Nothing coming in.", style = ComingInTypography.body, color = C.muted)
Text("New requests show up here.", style = ComingInTypography.body, color = C.muted)
}
SheetButton("Close", interactive = visible) { shell.closeRequests() }
}
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
else -> {
if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn)
data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) }
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
val start = state.windowStart
val visibleRows = remember(data, start, state.windowSize) {
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
val start = frame.start
val listState = rememberLazyListState()
val placement = rememberRequestPlacement(frame, listState)
val visibleRows = remember(data, start, frame.windowSize) {
data.ordered.subList(start, (start + frame.windowSize).coerceAtMost(data.ordered.size))
}
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier),
state = listState, verticalArrangement = Arrangement.spacedBy(T.listGap)) {
val keys = visibleRows.map { it.id }.uniqueKeys()
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { _, item ->
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { index, item ->
ComingInRow(item, shell.person, !frame.recoveryFocused && item.id == frame.focusedId, frame.expanded && item.id == frame.focusedId,
frame.ratingsVisible && data.signedIn && item.id == frame.focusedId,
onFocus = { if (visible) state.focus(item.id) }, onClick = { if (visible) { state.focus(item.id); shell.activateRequest() } },
modifier = Modifier.layout { measurable, constraints ->
val row = measurable.measure(constraints)
layout(row.width, row.height) {
row.placeRelative(0, placement.offset(keys[index], listState.layoutInfo))
}
},
readinessProgress = { placement.progress.value }, interactive = visible)
}
}
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() }
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = frame.recoveryFocused, interactive = visible) { shell.retryRequests() }
}
}
}
}
@Composable
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) }
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, interactive: Boolean = true, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = { if (interactive) onClick() }) }
}
@Composable
@ -128,32 +174,95 @@ private fun ComingInSkeleton() {
}
}
private data class RequestsPanelFrame(val data: ComingInSnapshot, val focusedId: String?, val expanded: Boolean,
val start: Int, val windowSize: Int, val recoveryFocused: Boolean, val ratingsVisible: Boolean, val expansionInput: Long)
private class RequestPlacementMemory(var frame: RequestsPanelFrame, var motion: RequestsPlacement? = null)
/** Placement reads one clock; target-offset caching happens only when the list is measured. */
private class RequestsPlacement(val animate: Boolean, private val origins: Map<Any, Int>) {
val progress: Animatable<Float, AnimationVector1D> = Animatable(if (animate) 0f else 1f)
private var measured: LazyListLayoutInfo? = null
private var targets: Map<Any, Int> = emptyMap()
fun offset(key: Any, info: LazyListLayoutInfo): Int {
val progress = progress.value
if (!animate || progress == 1f) return 0
if (measured !== info) {
targets = info.visibleItemsInfo.associate { it.key to it.offset }
measured = info
}
val target = targets[key] ?: return 0
return (((origins[key] ?: target) - target) * (1f - progress)).roundToInt()
}
}
/** Only remote expansion toggles animate; interruptions start at the current visual placement. */
@Composable
private fun rememberRequestPlacement(frame: RequestsPanelFrame, listState: LazyListState): RequestsPlacement {
val previous = remember { RequestPlacementMemory(frame) }
val reduced = reducedMotion()
val motion = remember(frame, reduced) {
val animate = !reduced && previous.frame.let {
it.expansionInput != frame.expansionInput && it.data === frame.data && it.focusedId == frame.focusedId &&
it.start == frame.start && it.expanded != frame.expanded
}
val info = listState.layoutInfo
val origins = if (animate) info.visibleItemsInfo.associate {
it.key to it.offset + (previous.motion?.offset(it.key, info) ?: 0)
} else emptyMap()
RequestsPlacement(animate, origins)
}
SideEffect { previous.frame = frame; previous.motion = motion }
LaunchedEffect(motion) {
if (motion.animate) motion.progress.animateTo(1f, tween(M.lateLineFade, easing = M.easeOut))
}
return motion
}
/** Thumbnail slots do not resize when the text or the readiness block grows. */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
@Composable
fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean,
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier,
readinessProgress: () -> Float = { 1f }, interactive: Boolean = true) {
val foreground = if (focused) C.onFocus else C.fg
val secondary = if (focused) C.onFocus else C.muted
val context = LocalContext.current
val request = remember(item.poster, context) {
item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) }
}
val posterReady = remember(request) { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
val posterState = remember(request) { mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty) }
var collapsedHeight by remember { mutableIntStateOf(0) }
Row(modifier.fillMaxWidth().testTag("coming-row-${item.id}")
.onSizeChanged { if (!expanded) collapsedHeight = it.height }
.drawWithContent {
val progress = readinessProgress()
if (expanded && collapsedHeight > 0 && progress < 1f) {
val bottom = collapsedHeight + (size.height - collapsedHeight) * progress
clipRect(left = -size.width, top = -size.height, right = size.width * 2, bottom = bottom) {
this@drawWithContent.drawContent()
}
} else drawContent()
}.focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
.pointerInput(item.id) {
.then(if (interactive) Modifier.pointerInput(item.id) {
awaitPointerEventScope {
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
}
}.clickable(onClick = onClick)
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"),
} else Modifier).clickable(enabled = interactive, onClick = onClick)
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
.background(if (posterReady.value) Color.Transparent else C.surface3),
onState = { posterReady.value = it is AsyncImagePainter.State.Success },
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
Box(Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
.background(if (posterState.value is AsyncImagePainter.State.Success) Color.Transparent else C.surface3)
.testTag("coming-thumb-${item.id}"), contentAlignment = Alignment.Center) {
AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null,
modifier = Modifier.fillMaxSize(), onState = { posterState.value = it },
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
if (request == null || posterState.value is AsyncImagePainter.State.Error) {
Glyph(if (item.mediaType == "movie") "film" else "tv", C.dim, Modifier.size(T.thumbGlyph))
}
}
Column(Modifier.weight(1f)) {
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)

View file

@ -82,6 +82,8 @@ class ShellState(
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
var recoveryIndex by mutableStateOf(0)
var requestsVisible by mutableStateOf(false)
internal var requestsExpansionInput = 0L
private set
var toast by mutableStateOf<String?>(null)
private val personRequests = mutableMapOf<String, ComingInState>()
val requests: ComingInState get() = personRequests.getValue(person.key)
@ -161,8 +163,12 @@ class ShellState(
when {
key == ShellKey.Back -> closeRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
requests.key(key) -> activateRequest()
key == ShellKey.Ok && requests.snapshot.items.isEmpty() && requests.snapshot.status != ComingInStatus.Unavailable -> closeRequests()
else -> {
val expanded = requests.expanded
if (requests.key(key)) activateRequest()
if ((key == ShellKey.Right || key == ShellKey.Left) && requests.expanded != expanded) requestsExpansionInput++
}
}
return true
}

View file

@ -96,12 +96,14 @@ fun ShellSurface(
if (state.pickerVisible) {
WhosWatching(state)
} else {
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
FocusRingHost {
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
}
NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
if (state.requestsVisible) ComingInSheet(state)
ComingInSheet(state)
overlay()
}
state.toast?.let { RecoveryToast(it) }
RecoveryToastHost(state.toast)
}
}