Polish title sheets, playback handoff and season motion
This commit is contained in:
parent
74b737fb52
commit
f05b0de156
13 changed files with 820 additions and 78 deletions
|
|
@ -21,7 +21,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -116,7 +115,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
|
|||
else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2)
|
||||
}
|
||||
val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset
|
||||
if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec)
|
||||
if (target != current) animateFrameScroll(list, target - current)
|
||||
}
|
||||
Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" }
|
||||
.titleWheel { wheelPending = true; shell.navFocused = false }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
|
|
@ -33,6 +39,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs
|
|||
height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f,
|
||||
content: @Composable BoxScope.() -> Unit) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||
val animatedProgress = rememberTitleProgress(null, progress.coerceIn(0f, 1f), hasResumeLine = progress > 0f)
|
||||
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2)
|
||||
|
|
@ -51,9 +58,21 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs
|
|||
val top = Offset(0f, size.height - edge - fill)
|
||||
if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill))
|
||||
drawRect(WolfskinTokens.Color.accent, top,
|
||||
Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill))
|
||||
Size(size.width * if (progress > 0f) animatedProgress.value else 1f, fill))
|
||||
}
|
||||
}
|
||||
}, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/** Target and line-kind changes cut; a held resume line interpolates even when runtime arrives late. */
|
||||
@Composable
|
||||
internal fun rememberTitleProgress(identity: String?, fraction: Float, hasResumeLine: Boolean): State<Float> {
|
||||
val reduced = reducedMotion()
|
||||
val progress = remember(identity, hasResumeLine) { Animatable(fraction) }
|
||||
LaunchedEffect(progress, fraction, reduced) {
|
||||
if (reduced) progress.snapTo(fraction)
|
||||
else progress.animateTo(fraction, tween(WolfskinTokens.Motion.heroFade, easing = WolfskinTokens.Motion.easeInOut))
|
||||
}
|
||||
return if (reduced) rememberUpdatedState(fraction) else progress.asState()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.Role
|
||||
|
|
@ -25,24 +26,35 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
|||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** Retained sheet controls are painted but cannot receive focus, hover or clicks. */
|
||||
internal val LocalTitleControlsEnabled = compositionLocalOf { true }
|
||||
|
||||
/** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */
|
||||
@Composable
|
||||
fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, onClick: () -> Unit) {
|
||||
fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, busy: Boolean = false, busyLabel: String? = null, onClick: () -> Unit) {
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
val enabled = LocalTitleControlsEnabled.current
|
||||
val background = if (option.primary) C.fg else if (overArtwork) {
|
||||
if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary
|
||||
} else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||
val foreground = if (option.primary) C.onFocus else C.fg
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
|
||||
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
||||
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||
.background(background, shape)
|
||||
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
||||
.clickable(enabled = enabled && !busy) { focusOwner?.invoke(); onClick() }.then(dimensions),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
|
||||
if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
|
||||
if (busyLabel != null) BusyLabelSwap(busy, busyLabel, foreground, WolfskinTokens.Type.button) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(L.buttonIcon))
|
||||
Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
|
||||
}
|
||||
} else {
|
||||
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
|
||||
if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import androidx.compose.animation.core.animateDpAsState
|
|||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.platform.testTag
|
||||
|
|
@ -13,7 +15,6 @@ 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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
|
|
@ -86,10 +87,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
state.data.playTarget?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target ->
|
||||
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (target.resumeSeconds > 0) {
|
||||
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
|
||||
val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f
|
||||
Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(C.accent))
|
||||
}
|
||||
val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f
|
||||
val progress = rememberTitleProgress(target.id, fraction, hasResumeLine = true)
|
||||
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))
|
||||
.drawBehind { drawRect(C.accent, size = Size(size.width * progress.value, size.height)) })
|
||||
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress)
|
||||
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
|
||||
}
|
||||
|
|
@ -97,7 +98,8 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index,
|
||||
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
|
||||
onFocus = { state.focus("header", index) }) { state.click("header", index) } }
|
||||
onFocus = { state.focus("header", index) }, busy = option.id == "play" && state.playbackStart.starting == "play",
|
||||
busyLabel = if (option.id == "play") "Starting…" else null) { state.click("header", index) } }
|
||||
}
|
||||
if (state.options.any { it.icon }) {
|
||||
val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon }
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -41,15 +44,21 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) {
|
|||
LaunchedEffect(visible, reduced) {
|
||||
if (visible) focus.requestFocus()
|
||||
opacity.animateTo(if (visible) 1f else 0f,
|
||||
tween(motionDuration(if (visible) WolfskinTokens.Motion.base else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||
tween(motionDuration(if (visible) WolfskinTokens.Motion.sheetIn else WolfskinTokens.Motion.sheetOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||
}
|
||||
if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) {
|
||||
Box(Modifier.fillMaxSize().background(C.sheetScrim)
|
||||
.clickable { if (visible) state.back(shell) }
|
||||
.clickable(enabled = visible) { state.back(shell) }
|
||||
.pointerInput(visible) {
|
||||
if (!visible) awaitPointerEventScope {
|
||||
while (true) awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||
val key = readShellKey(event)
|
||||
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
}.focusable(enabled = visible)) {
|
||||
CompositionLocalProvider(LocalTitleControlsEnabled provides visible) {
|
||||
Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth)
|
||||
.graphicsLayer { alpha = opacity.value }
|
||||
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
|
|
@ -64,6 +73,7 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
|
|
@ -44,25 +50,65 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
/** Sheets sit above navigation and consume pointer and key input across the entire scrim. */
|
||||
@Composable
|
||||
fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
||||
val visible = state.sheet != null && state.sheet != TitleSheet.Menu
|
||||
val menu = state.sheet == TitleSheet.Menu
|
||||
val previous = remember { arrayOf<TitleSheet?>(null) }
|
||||
val retainedSheet = remember { arrayOf<TitleSheet?>(null) }
|
||||
val retained = remember(state) { TitleState(state.data, state.person) { } }
|
||||
val panelScrim = remember { booleanArrayOf(false) }
|
||||
val ownsScrim = visible || (panelScrim[0] && !menu)
|
||||
val fromMenu = visible && previous[0] == TitleSheet.Menu
|
||||
val fade = rememberSheetFade(visible)
|
||||
val scrim = remember { Animatable(0f) }
|
||||
val reduced = reducedMotion()
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(state.sheet) { focus.requestFocus() }
|
||||
SideEffect {
|
||||
previous[0] = state.sheet
|
||||
if (visible || menu) panelScrim[0] = visible
|
||||
if (visible) {
|
||||
retainedSheet[0] = state.sheet
|
||||
retained.update(state.data)
|
||||
retained.sheetIndex = state.sheetIndex
|
||||
retained.versionId = state.versionId
|
||||
retained.audioIndex = state.audioIndex
|
||||
retained.subtitleIndex = state.subtitleIndex
|
||||
retained.watched = state.watched
|
||||
}
|
||||
}
|
||||
LaunchedEffect(visible, menu, reduced) {
|
||||
if (visible) focus.requestFocus()
|
||||
if (menu || fromMenu) scrim.snapTo(1f)
|
||||
if (!menu) scrim.animateTo(if (visible) 1f else 0f,
|
||||
tween(motionDuration(if (visible) WolfskinTokens.Motion.sheetIn else WolfskinTokens.Motion.sheetOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||
}
|
||||
if (!visible && fade.value == 0f && (!ownsScrim || scrim.value == 0f)) return
|
||||
val sheet = if (visible) state.sheet else retainedSheet[0]
|
||||
val content = if (visible) state else retained
|
||||
val shadow = remember { Shadow(radius = D.sheetShadowRadius, offset = DpOffset.Zero.copy(x = D.sheetShadowX), color = Color.Black.copy(alpha = D.sheetShadowOpacity)) }
|
||||
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { }
|
||||
.testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" }
|
||||
Box(Modifier.fillMaxSize().sheetScrim { if (!ownsScrim) 0f else if (fromMenu) 1f else scrim.value }
|
||||
.pointerInput(visible) {
|
||||
awaitPointerEventScope {
|
||||
while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
.testTag("title-sheet").semantics { stateDescription = "$sheet:${content.sheetIndex}" }
|
||||
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||
val key = readShellKey(event)
|
||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface)
|
||||
.verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
|
||||
gap = if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) {
|
||||
when (state.sheet) {
|
||||
TitleSheet.Version -> VersionChoices(state)
|
||||
TitleSheet.Tracks -> TrackChoices(state)
|
||||
TitleSheet.MediaInfo -> MediaInformation(state)
|
||||
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
|
||||
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
|
||||
TitleSheet.Menu, null -> Unit
|
||||
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable(enabled = visible)) {
|
||||
CompositionLocalProvider(LocalTitleControlsEnabled provides visible, LocalTitleFocusOwner provides null) {
|
||||
CssColumn(Modifier.sheetPanelFade { fade.value }.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight()
|
||||
.dropShadow(RectangleShape, shadow).background(C.surface)
|
||||
.verticalScroll(rememberScrollState(), enabled = visible).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
|
||||
gap = if (sheet == TitleSheet.Tracks) D.trackGap else if (sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) {
|
||||
when (sheet) {
|
||||
TitleSheet.Version -> VersionChoices(content)
|
||||
TitleSheet.Tracks -> TrackChoices(content)
|
||||
TitleSheet.MediaInfo -> MediaInformation(content)
|
||||
TitleSheet.ConfirmWatched -> WatchedConfirmation(content)
|
||||
TitleSheet.HiddenReceipt -> HiddenReceipt(content)
|
||||
TitleSheet.Menu, null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -100,11 +146,12 @@ private fun TrackChoices(state: TitleState) {
|
|||
|
||||
@Composable
|
||||
private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
|
||||
val enabled = LocalTitleControlsEnabled.current
|
||||
val foreground = if (focused) C.onFocus else C.fg
|
||||
val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical)
|
||||
Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||
.clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
|
||||
.clickable(enabled = enabled, onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
CssColumn(Modifier.weight(1f)) {
|
||||
Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle))
|
||||
|
|
|
|||
|
|
@ -28,9 +28,23 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -54,6 +68,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
|||
val view = screenView(shell)
|
||||
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
|
||||
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
|
||||
if (state.playbackStart.launch != null) return@onPreviewKeyEvent true
|
||||
// No content target exists during an initial load; Down must not enter a skeleton.
|
||||
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
|
||||
else when (readShellKey(event)) {
|
||||
|
|
@ -69,9 +84,10 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
|||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
||||
}
|
||||
if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell)
|
||||
TitleMenu(state, shell)
|
||||
if (!shell.pickerVisible) TitleOptionsSheet(state, shell)
|
||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
||||
PlaybackHandoffVeil(state.playbackStart)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +97,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism
|
|||
LaunchedEffect(message) {
|
||||
if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() }
|
||||
}
|
||||
if (message != null) RecoveryToast(message, modifier)
|
||||
RecoveryToastHost(message, modifier)
|
||||
}
|
||||
|
||||
/** One keyed vertical frame renders the exact header, season, cast and related order. */
|
||||
|
|
@ -92,6 +108,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
val entryActive = LocalScreenActive.current
|
||||
var wheelPending by remember { mutableStateOf(false) }
|
||||
val focus = remember { FocusRequester() }
|
||||
val sheetWasOpen = remember { booleanArrayOf(state.sheet != null) }
|
||||
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
||||
val compactHeader = state.data.status == TitleReadStatus.Ready
|
||||
val item = state.data.item
|
||||
|
|
@ -99,7 +116,12 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero
|
||||
val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead
|
||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) {
|
||||
if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||
val restoring = sheetWasOpen[0] && state.sheet == null
|
||||
sheetWasOpen[0] = state.sheet != null
|
||||
if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) {
|
||||
if (restoring) withFrameNanos { }
|
||||
focus.requestFocus()
|
||||
}
|
||||
}
|
||||
val displayedRows = remember(state.data) {
|
||||
buildList {
|
||||
|
|
@ -138,7 +160,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
|
||||
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
|
||||
else y - with(density) { L.pinnedLine.toPx() }
|
||||
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
|
||||
if (target != position) animateFrameScroll(list, target - position)
|
||||
}
|
||||
val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
|
||||
val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } }
|
||||
|
|
@ -197,13 +219,66 @@ private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) {
|
|||
private fun TitleSeasons(state: TitleState, active: Boolean) {
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
val list = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season)
|
||||
LaunchedEffect(visibleChip, state.data.seasonNumbers) {
|
||||
val reduced = reducedMotion()
|
||||
val selectedIndex = state.data.seasonNumbers.indexOf(state.season)
|
||||
val visibleChip = if (active) state.column else selectedIndex
|
||||
LaunchedEffect(visibleChip, state.data.seasonNumbers, reduced) {
|
||||
if (visibleChip >= 0) {
|
||||
if (NetworkArtTransition.reduced) list.scrollToItem(visibleChip) else list.animateScrollToItem(visibleChip)
|
||||
val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == visibleChip }
|
||||
if (visible == null) list.scrollToItem(visibleChip)
|
||||
else animateFrameScroll(list, visible.offset.toFloat())
|
||||
}
|
||||
}
|
||||
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
||||
var capsuleIndex by remember { mutableIntStateOf(selectedIndex) }
|
||||
val capsulePosition = remember { Animatable(0f) }
|
||||
val capsuleWidth = remember { Animatable(0f) }
|
||||
// A stable virtual row coordinate preserves the spring's frame time and velocity on retarget.
|
||||
var capsuleTarget by remember { mutableFloatStateOf(0f) }
|
||||
val selection = rememberUpdatedState(selectedIndex)
|
||||
LaunchedEffect(list, reduced) {
|
||||
snapshotFlow { selection.value }.collectLatest { index ->
|
||||
val target = snapshotFlow { list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } }.filterNotNull().first()
|
||||
val previous = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == capsuleIndex }
|
||||
if (reduced || capsuleWidth.value == 0f || previous == null) {
|
||||
capsulePosition.snapTo(0f)
|
||||
capsuleWidth.snapTo(target.size.toFloat())
|
||||
capsuleTarget = 0f
|
||||
capsuleIndex = index
|
||||
} else {
|
||||
val targetPosition = capsuleTarget + target.offset - previous.offset
|
||||
capsuleTarget = targetPosition
|
||||
capsuleIndex = index
|
||||
launch { capsulePosition.animateTo(targetPosition, spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness)) }
|
||||
if (capsuleWidth.targetValue != target.size.toFloat()) launch {
|
||||
capsuleWidth.animateTo(target.size.toFloat(), spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop).drawWithCache {
|
||||
val height = D.chipHeight.toPx()
|
||||
val radius = WolfskinTokens.Radius.chip.toPx()
|
||||
val border = D.chipBorder.toPx()
|
||||
val stroke = Stroke(border)
|
||||
val inset = Offset(border / 2, border / 2)
|
||||
val outerCorner = CornerRadius(radius)
|
||||
val innerCorner = CornerRadius(radius - border / 2)
|
||||
onDrawBehind {
|
||||
val layout = list.layoutInfo
|
||||
val visible = layout.visibleItemsInfo
|
||||
val selected = capsuleIndex
|
||||
var index = 0
|
||||
while (index < visible.size) {
|
||||
val chip = visible[index++]
|
||||
if (chip.index != selected) continue
|
||||
val topLeft = Offset(chip.offset + layout.beforeContentPadding + capsulePosition.value - capsuleTarget, 0f)
|
||||
val bounds = Size(capsuleWidth.value, height)
|
||||
drawRoundRect(C.accentSoft, topLeft, bounds, outerCorner)
|
||||
drawRoundRect(C.accent, topLeft + inset, Size(bounds.width - border, bounds.height - border), innerCorner, style = stroke)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) {
|
||||
val seasonNumbers = state.data.seasonNumbers
|
||||
val keys = seasonNumbers.map { "season-$it" }.uniqueKeys()
|
||||
items(seasonNumbers.size, key = { keys[it] }, contentType = { "chip" }) { index ->
|
||||
|
|
@ -211,12 +286,12 @@ private fun TitleSeasons(state: TitleState, active: Boolean) {
|
|||
val focused = active && state.column == index
|
||||
val selected = state.season == number
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.chip)
|
||||
val label = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg)
|
||||
Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||
.onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) }
|
||||
.background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape)
|
||||
.then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier)
|
||||
.background(if (focused) C.fg else if (selected) androidx.compose.ui.graphics.Color.Transparent else C.chipRest, shape)
|
||||
.clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
|
||||
Text("Season $number", color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label)
|
||||
Text("Season $number", color = label, style = WolfskinTokens.Type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -238,4 +313,3 @@ private fun TitleReadError(state: TitleState) {
|
|||
}
|
||||
}
|
||||
|
||||
internal val titleFrameScrollSpec get() = tween<Float>(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ class TitleState(
|
|||
private val centers = HashMap<String, MutableMap<Int, Float>>()
|
||||
fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x }
|
||||
fun showSeasonLoading() { if (data.episodesStatus != TitleReadStatus.Loading) data = data.copy(episodesStatus = TitleReadStatus.Loading) }
|
||||
/** Cached episodes replace the loading state on the season-selection frame. */
|
||||
fun showSeason(number: Int, episodes: List<TitleItem>) {
|
||||
season = number
|
||||
data = data.copy(selectedSeason = number, episodes = episodes, episodesStatus = TitleReadStatus.Ready)
|
||||
}
|
||||
fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) }
|
||||
var sheet by mutableStateOf<TitleSheet?>(null)
|
||||
private set
|
||||
|
|
@ -37,6 +42,7 @@ class TitleState(
|
|||
var subtitleIndex by mutableStateOf(initial.selectedSubtitle)
|
||||
var watched by mutableStateOf(initial.watchTarget.watched)
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
val playbackStart = PlaybackStart()
|
||||
private var armedCard: String? = null
|
||||
private var seasonChosen = false
|
||||
val requestedSeason: Int? get() = season.takeIf { seasonChosen }
|
||||
|
|
@ -115,6 +121,7 @@ class TitleState(
|
|||
|
||||
/** Key-up can only activate the same card that armed the first key-down. */
|
||||
fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean {
|
||||
if (playbackStart.launch != null) return true
|
||||
if (key == ShellKey.Ok) {
|
||||
if (!down) {
|
||||
okHeld = false
|
||||
|
|
@ -147,6 +154,8 @@ class TitleState(
|
|||
|
||||
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
||||
fun back(shell: ShellState) {
|
||||
if (playbackStart.launch != null) return
|
||||
playbackStart.fail()
|
||||
when {
|
||||
sheet != null -> closeSheet()
|
||||
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
||||
|
|
@ -187,7 +196,7 @@ class TitleState(
|
|||
|
||||
private fun activateOption(id: String) {
|
||||
when (id) {
|
||||
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
||||
"play", "restart" -> if (data.watchReady && playbackStart.starting == null) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
||||
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
||||
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
||||
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue