Polish Requests transitions and shell hosts
This commit is contained in:
parent
74b737fb52
commit
2572da00fd
5 changed files with 466 additions and 39 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue