Refine late metadata fades and synchronize loading motion
All checks were successful
review/code-claude PASS on 16684454, delta fdff85c8 reviewed
review/design-claude PASS on 16684454, delta fdff85c8 reviewed
All checks were successful
review/code-claude PASS on 16684454, delta fdff85c8 reviewed
review/design-claude PASS on 16684454, delta fdff85c8 reviewed
This commit is contained in:
parent
1668445418
commit
fdff85c8a1
7 changed files with 149 additions and 15 deletions
|
|
@ -5,6 +5,7 @@ import androidx.compose.animation.core.tween
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
|
@ -22,13 +23,16 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
|
|||
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
|
||||
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
|
||||
val alpha = remember(title, facts) { Animatable(0f) }
|
||||
val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) }
|
||||
val reduced = reducedMotion()
|
||||
val rise = with(androidx.compose.ui.platform.LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
|
||||
LaunchedEffect(alpha, visible, reduced) {
|
||||
if (visible && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
|
||||
alpha.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||
alpha.animateTo(if (visible) 1f else 0f, tween(motionDuration(if (visible) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) {
|
||||
if (visible) entryProgress.floatValue = value
|
||||
}
|
||||
}
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - alpha.value) }) {
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) {
|
||||
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
|
||||
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
|||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -28,14 +38,40 @@ fun reducedMotion(): Boolean = LocalReducedMotion.current ||
|
|||
/** Short fades remain available when movement and continuous animation are disabled. */
|
||||
fun motionDuration(duration: Int, reduced: Boolean): Int = if (reduced) minOf(duration, M.reducedFade) else duration
|
||||
|
||||
@Stable
|
||||
private class SkeletonPulseClock {
|
||||
var subscribers by mutableIntStateOf(0)
|
||||
val idle: State<Float> = mutableFloatStateOf(M.skeletonLowAlpha)
|
||||
var alpha by mutableStateOf(idle)
|
||||
}
|
||||
|
||||
private val LocalSkeletonPulseClock = compositionLocalOf<SkeletonPulseClock> {
|
||||
error("Skeleton loading requires WolfskinTheme")
|
||||
}
|
||||
|
||||
/** Share phase across shelves, including late arrivals; stop the clock when no shelf needs it. */
|
||||
@Composable
|
||||
internal fun SkeletonPulseScope(content: @Composable () -> Unit) {
|
||||
val clock = remember { SkeletonPulseClock() }
|
||||
val reduced = reducedMotion()
|
||||
val alpha = if (clock.subscribers > 0 && !reduced) {
|
||||
rememberInfiniteTransition(label = "skeleton-screen").animateFloat(M.skeletonLowAlpha, 1f,
|
||||
infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe")
|
||||
} else clock.idle
|
||||
SideEffect { clock.alpha = alpha }
|
||||
CompositionLocalProvider(LocalSkeletonPulseClock provides clock, content = content)
|
||||
}
|
||||
|
||||
/** One alpha layer owns all shapes in a shelf; individual placeholders never animate. */
|
||||
@Composable
|
||||
fun Modifier.skeletonPulse(loading: Boolean = true): Modifier {
|
||||
if (!loading || reducedMotion()) return this
|
||||
val clock = rememberInfiniteTransition(label = "skeleton-shelf")
|
||||
val alpha = clock.animateFloat(M.skeletonLowAlpha, 1f,
|
||||
infiniteRepeatable(tween(M.skeletonCycle / 2, easing = M.breatheEase), RepeatMode.Reverse), label = "breathe")
|
||||
return graphicsLayer { this.alpha = alpha.value }
|
||||
val clock = LocalSkeletonPulseClock.current
|
||||
DisposableEffect(clock) {
|
||||
clock.subscribers++
|
||||
onDispose { clock.subscribers-- }
|
||||
}
|
||||
return graphicsLayer { alpha = clock.alpha.value }
|
||||
}
|
||||
|
||||
/** Content enters on its row layer, never one animation per card or a full-screen transition. */
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ fun TitleRatings(ratings: List<LibraryRating>, modifier: Modifier = Modifier, st
|
|||
val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) }
|
||||
val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap
|
||||
val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(if (plate) modifier else modifier.lateLineFade(ratings), horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
ratings.forEach { rating ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
val resource = when (rating.source) {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings)
|
||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body))
|
||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres))
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) {
|
||||
state.data.requestedStage?.let { stage ->
|
||||
|
|
@ -69,7 +69,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
}
|
||||
}
|
||||
}
|
||||
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
|
||||
Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).lateLineFade((episode ?: item).overview).testTag("title-overview"))
|
||||
if (!item.held && state.data.connection != TitleConnection.SignedIn) {
|
||||
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
|
||||
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ fun WolfskinTheme(content: @Composable () -> Unit) {
|
|||
val reduced = reducedMotion()
|
||||
androidx.compose.runtime.SideEffect { NetworkArtTransition.reduced = reduced }
|
||||
androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) {
|
||||
MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content)
|
||||
MaterialTheme(colorScheme = WolfskinColors.darkScheme) {
|
||||
SkeletonPulseScope(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue