Polish title sheets, playback handoff and season motion

This commit is contained in:
justinzeus 2026-11-08 04:00:17 +01:00
parent 74b737fb52
commit f05b0de156
13 changed files with 820 additions and 78 deletions

View file

@ -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 }

View file

@ -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()
}

View file

@ -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)
}
}
}

View file

@ -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 }

View file

@ -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) {
}
}
}
}
}
}
}

View file

@ -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))

View file

@ -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)

View file

@ -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 }