Match title CSS geometry and fix modal focus and subtitle choices
This commit is contained in:
parent
e243894a20
commit
2fdd459037
21 changed files with 297 additions and 74 deletions
|
|
@ -24,7 +24,7 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
|
|||
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||
}
|
||||
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
|
||||
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, modifier = Modifier.cssLineBox(WolfskinTokens.Type.button), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
CaptionFacts(facts, accentFirstFact, accentLastFact)
|
||||
}
|
||||
}
|
||||
|
|
@ -40,8 +40,8 @@ private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolea
|
|||
content = {
|
||||
parts.forEachIndexed { index, part ->
|
||||
val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast)
|
||||
Text(part, color = if (accented) colour else muted, style = style, maxLines = 1, overflow = TextOverflow.Clip)
|
||||
if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style)
|
||||
Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.cssLineBox(style), maxLines = 1, overflow = TextOverflow.Clip)
|
||||
if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.cssLineBox(style))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().clipToBounds(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.FirstBaseline
|
||||
import androidx.compose.ui.layout.LastBaseline
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.LayoutModifier
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.layout.ParentDataModifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** CSS leading constrains the line box, even when the platform font's natural box is taller. */
|
||||
internal fun Modifier.cssLineBox(style: TextStyle, top: Dp = 0.dp, bottom: Dp = 0.dp): Modifier =
|
||||
then(CssLineBox(style.lineHeight, top, bottom))
|
||||
|
||||
private data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssLineBox
|
||||
fun Density.exactHeight(height: Int): Float {
|
||||
val margins = top.toPx() + bottom.toPx()
|
||||
return ((height - margins) / lineHeight.toPx()).roundToInt().coerceAtLeast(1) * lineHeight.toPx() + margins
|
||||
}
|
||||
override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints) = with(measurable.measure(constraints.copy(minHeight = 0))) {
|
||||
val line = lineHeight.toPx()
|
||||
val lines = ((this[LastBaseline] - this[FirstBaseline]) / line).roundToInt().coerceAtLeast(0) + 1
|
||||
val textHeight = lines * line
|
||||
val offset = top.toPx() + (textHeight - height) / 2f
|
||||
layout(width, (textHeight + top.toPx() + bottom.toPx()).roundToInt()) {
|
||||
placeRelative(0, offset.roundToInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CssColumnHeight : ParentDataModifier {
|
||||
var pixels = 0f
|
||||
override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssColumnHeight
|
||||
}
|
||||
|
||||
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
|
||||
@Composable
|
||||
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = 0.dp, content: @Composable () -> Unit) {
|
||||
val exact = remember { CssColumnHeight() }
|
||||
Layout(content, modifier.then(exact)) { children, constraints ->
|
||||
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }
|
||||
fun height(index: Int): Float = when (val data = children[index].parentData) {
|
||||
is CssLineBox -> with(data) { exactHeight(measured[index].height) }
|
||||
is CssColumnHeight -> data.pixels
|
||||
else -> measured[index].height.toFloat()
|
||||
}
|
||||
val space = gap.toPx()
|
||||
var total = 0f
|
||||
var width = constraints.minWidth
|
||||
for (index in measured.indices) {
|
||||
total += height(index) + if (index == measured.lastIndex) 0f else space
|
||||
width = maxOf(width, measured[index].width)
|
||||
}
|
||||
exact.pixels = total
|
||||
layout(width.coerceIn(constraints.minWidth, constraints.maxWidth), total.roundToInt().coerceIn(constraints.minHeight, constraints.maxHeight)) {
|
||||
var y = 0f
|
||||
for (index in measured.indices) {
|
||||
measured[index].placeRelative(0, y.roundToInt())
|
||||
y += height(index) + space
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ class PersonState(initialPerson: TitlePerson, initialCredits: List<TitleItem>, p
|
|||
@Composable
|
||||
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
|
||||
LaunchedEffect(state) { shell.navFocused = false }
|
||||
Box(modifier.fillMaxSize().background(C.bg)) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
|
||||
ShellSurface(shell, handlesContentKeys = false, savedAt = state.savedAt) { PersonPage(state, shell, it) }
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
|||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
|
|
@ -46,7 +47,6 @@ fun TitleRatings(ratings: List<LibraryRating>, modifier: Modifier = Modifier, st
|
|||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
|
||||
ratings.forEach { rating ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
|
||||
val resource = when (rating.source) {
|
||||
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
|
||||
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
|
||||
|
|
@ -57,7 +57,8 @@ fun TitleRatings(ratings: List<LibraryRating>, modifier: Modifier = Modifier, st
|
|||
val desaturated = if (unknownCritic) remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) } else null
|
||||
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f, markHeight),
|
||||
alpha = if (unknownCritic) P.unknownCriticOpacity else 1f, colorFilter = desaturated)
|
||||
Text(rating.formatted(), color = C.fg, style = numberStyle, maxLines = 1)
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), modifier = Modifier.cssLineBox(textStyle))
|
||||
Text(rating.formatted(), color = C.fg, style = numberStyle, modifier = Modifier.cssLineBox(numberStyle), maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +73,7 @@ fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifi
|
|||
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
|
||||
BoxWithConstraints(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }
|
||||
.background(Brush.verticalGradient(0f to C.bg.copy(alpha = P.plateOpacity), P.plateMidStop to C.bg.copy(alpha = P.plateMidOpacity), 1f to C.bg.copy(alpha = 0f)))
|
||||
.padding(start = P.plateSide, end = P.plateSide, top = P.plateTop, bottom = P.plateBottom)) {
|
||||
.testTag("rating-plate").padding(horizontal = P.plateSide)) {
|
||||
val width = with(density) { maxWidth.toPx() }
|
||||
val fit = remember(ratings, width, density, Locale.getDefault()) {
|
||||
var size = WolfskinTokens.Type.ratingPlate.fontSize.value
|
||||
|
|
@ -99,7 +100,8 @@ fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifi
|
|||
count to size
|
||||
}
|
||||
val fontSize = TextUnit(fit.second, TextUnitType.Sp)
|
||||
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize,
|
||||
lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value)), plate = true)
|
||||
val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize,
|
||||
lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value))
|
||||
TitleRatings(ratings.take(fit.first), modifier = Modifier.cssLineBox(style, top = P.plateTop, bottom = P.plateBottom + P.plateInlineDescent), style = style, plate = true)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod
|
|||
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)
|
||||
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
|
||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
||||
.background(background, shape)
|
||||
.then(if (option.on) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
|
||||
.then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
|
||||
.clickable { 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))
|
||||
|
|
@ -53,7 +53,7 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod
|
|||
fun TitleRadio(selected: Boolean, focused: Boolean) {
|
||||
val color = if (focused) C.onFocus else C.fg
|
||||
Box(Modifier.size(D.radioSize).background(if (selected) C.accent else androidx.compose.ui.graphics.Color.Transparent, CircleShape)
|
||||
.then(if (!selected) Modifier.border(D.radioBorder, color.copy(alpha = .35f), CircleShape) else Modifier), contentAlignment = Alignment.Center) {
|
||||
.then(if (focused || !selected) Modifier.border(D.radioBorder, color.copy(alpha = if (focused) .5f else .35f), CircleShape) else Modifier), contentAlignment = Alignment.Center) {
|
||||
if (selected) Box(Modifier.size(D.radioDot).background(C.onFocus, CircleShape))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,50 +37,50 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
|
||||
val item = state.data.item
|
||||
val type = if (item.kind == "movie") "Movie" else "Series"
|
||||
Column(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
|
||||
Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
|
||||
Row(Modifier.cssLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
|
||||
Text(buildString {
|
||||
append("$type · ${if (item.held) "In your library" else "Not in your library"}")
|
||||
if (item.held && state.watched) append(" · Watched")
|
||||
if (!item.held && item.otherOwners.isNotEmpty()) append(" · In ${item.otherOwners.joinToString(" and ")}’s library")
|
||||
if (state.data.hidden) append(" · Hidden from your picks")
|
||||
}, color = C.accentText, style = D.dLine)
|
||||
}, color = C.accentText, style = TitleTextStyles.dLine)
|
||||
}
|
||||
Text(item.name, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = D.titleBottom).testTag("title-heading"))
|
||||
Row(Modifier.heightIn(min = L.iconCaptionLine + D.factsBottom).padding(bottom = D.factsBottom), horizontalArrangement = Arrangement.spacedBy(D.factsGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
item.year?.let { Text(it.toString(), color = C.muted, style = WolfskinTokens.Type.body) }
|
||||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = WolfskinTokens.Type.body)
|
||||
if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = WolfskinTokens.Type.body)
|
||||
Text(item.name, color = C.fg, style = TitleTextStyles.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.cssLineBox(TitleTextStyles.displayDetail, bottom = D.titleBottom).testTag("title-heading"))
|
||||
Row(Modifier.padding(bottom = D.factsBottom).height(D.factsHeight).testTag("title-facts"), horizontalArrangement = Arrangement.spacedBy(D.factsGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body)) }
|
||||
if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(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.cssLineBox(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 = WolfskinTokens.Type.body)
|
||||
if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body))
|
||||
}
|
||||
state.data.requestedStage?.let { stage ->
|
||||
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
||||
Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.cssLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
|
||||
Text(stage, color = color, style = WolfskinTokens.Type.label)
|
||||
Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = D.dLine)
|
||||
Text(stage, color = color, style = TitleTextStyles.label)
|
||||
Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = TitleTextStyles.dLine)
|
||||
}
|
||||
}
|
||||
Text(item.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = D.overviewGap))
|
||||
Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.cssLineBox(TitleTextStyles.body, bottom = D.overviewGap).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 = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.overviewGap))
|
||||
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body, bottom = D.overviewGap))
|
||||
}
|
||||
state.data.playTarget?.let { target ->
|
||||
Row(Modifier.offset(y = D.progressLift).padding(bottom = D.progressBottom), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.cssLineBox(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))
|
||||
}
|
||||
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = WolfskinTokens.Type.label)
|
||||
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = WolfskinTokens.Type.label)
|
||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = WolfskinTokens.Type.label)
|
||||
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)
|
||||
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = TitleTextStyles.progress)
|
||||
}
|
||||
}
|
||||
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
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) } }
|
||||
|
|
@ -100,11 +100,15 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
|
|||
/** A no-copy load has no focusable content and never draws a spinner. */
|
||||
@Composable
|
||||
fun TitleHeaderSkeleton(modifier: Modifier = Modifier) {
|
||||
Column(modifier.offset(L.safeSide, L.detailCopyTop), verticalArrangement = Arrangement.spacedBy(D.factsBottom)) {
|
||||
Column(modifier.offset(L.safeSide, L.detailCopyTop)) {
|
||||
Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
||||
Spacer(Modifier.height(D.skeletonTitleGap))
|
||||
Box(Modifier.size(D.skeletonTitleWidth, D.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
Spacer(Modifier.height(D.skeletonFactsGap))
|
||||
Box(Modifier.size(D.skeletonFactsWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet)))
|
||||
Spacer(Modifier.height(D.skeletonOverviewGap))
|
||||
Box(Modifier.size(D.skeletonOverviewWidth, D.skeletonOverviewHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
Spacer(Modifier.height(D.overviewGap))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button)))
|
||||
repeat(2) { Box(Modifier.size(L.iconButton).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) }
|
||||
|
|
|
|||
|
|
@ -53,9 +53,9 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
|||
val key = readShellKey(event)
|
||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
Column(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface)
|
||||
CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface)
|
||||
.verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
|
||||
verticalArrangement = Arrangement.spacedBy(if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap)) {
|
||||
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)
|
||||
|
|
@ -70,24 +70,24 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
|||
|
||||
@Composable
|
||||
private fun VersionChoices(state: TitleState) {
|
||||
Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle)
|
||||
Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle))
|
||||
SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files")
|
||||
state.data.versions.forEachIndexed { index, version ->
|
||||
PlaybackChoice(version.name, listOf(version.video, version.bitrate, version.size).filter { it.isNotBlank() }.joinToString(" · "), version == state.version, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
|
||||
}
|
||||
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption)
|
||||
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackChoices(state: TitleState) {
|
||||
val version = state.version ?: return
|
||||
Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle)
|
||||
Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle))
|
||||
SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}")
|
||||
Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||
Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
|
||||
version.audio.forEachIndexed { index, track ->
|
||||
PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
|
||||
}
|
||||
Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||
Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
|
||||
val start = version.audio.size
|
||||
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
|
||||
version.subtitles.forEachIndexed { index, track ->
|
||||
|
|
@ -95,20 +95,20 @@ private fun TrackChoices(state: TitleState) {
|
|||
PlaybackChoice(track.label, null, track.index == state.subtitleIndex, state.sheetIndex == position, { state.sheetIndex = position }) { state.clickSheet(position) }
|
||||
}
|
||||
val done = start + version.subtitles.size + 1
|
||||
TitleControl(TitleOption("done", "Done", "", primary = true, icon = false), state.sheetIndex == done, Modifier.padding(top = D.trackGap), onFocus = { state.sheetIndex = done }) { state.clickSheet(done) }
|
||||
TitleControl(TitleOption("done", "Done", "", primary = true, icon = false), state.sheetIndex == done, Modifier.padding(top = D.sheetButtonTop).sheetFocused(state.sheetIndex == done), onFocus = { state.sheetIndex = done }) { state.clickSheet(done) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
|
||||
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().titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus).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),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(label, color = foreground, style = TitleTextStyles.listTitle)
|
||||
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption)
|
||||
CssColumn(Modifier.weight(1f)) {
|
||||
Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.cssLineBox(TitleTextStyles.listTitle))
|
||||
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption))
|
||||
}
|
||||
TitleRadio(selected, focused)
|
||||
}
|
||||
|
|
@ -117,20 +117,20 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus
|
|||
@Composable
|
||||
private fun MediaInformation(state: TitleState) {
|
||||
val version = state.version ?: return
|
||||
Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle)
|
||||
Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle))
|
||||
SheetSubtitle(state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name)
|
||||
MediaInfoSection("General", listOf("Container" to version.container, "Size" to version.size.ifEmpty { "not known" }, "Bitrate" to version.bitrate, "Runtime" to (state.target?.runtime ?: "not known")))
|
||||
MediaInfoSection("Video", listOf("Stream" to version.video))
|
||||
MediaInfoSection("Audio", version.audio.mapIndexed { index, track -> "Track ${index + 1}" to (track.label + if (track.isDefault) " (default)" else "") })
|
||||
MediaInfoSection("Subtitles", version.subtitles.mapIndexed { index, track -> "Track ${index + 1}" to track.label })
|
||||
TitleControl(TitleOption("close", "Close", "", primary = true, icon = false), true, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
TitleControl(TitleOption("close", "Close", "", primary = true, icon = false), true, Modifier.padding(top = D.sheetButtonTop).sheetFocused(true), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
|
||||
Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(D.infoRowGap)) { values.forEach { (name, value) ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
|
||||
Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
|
||||
CssColumn(gap = D.infoRowGap) { values.forEach { (name, value) ->
|
||||
Row(Modifier.fillMaxWidth().cssLineBox(TitleTextStyles.caption), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
|
||||
Text(name, color = C.dim, style = TitleTextStyles.infoKey, modifier = Modifier.width(D.infoLabelWidth))
|
||||
Text(value, color = C.fg, style = TitleTextStyles.caption)
|
||||
}
|
||||
|
|
@ -140,21 +140,17 @@ private fun MediaInfoSection(label: String, values: List<Pair<String, String>>)
|
|||
@Composable
|
||||
private fun WatchedConfirmation(state: TitleState) {
|
||||
val verb = if (state.watched) "unwatched" else "watched"
|
||||
Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle)
|
||||
Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle))
|
||||
SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) }
|
||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, Modifier.sheetFocused(state.sheetIndex == 0), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, Modifier.sheetFocused(state.sheetIndex == 1), onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetSubtitle(text: String) {
|
||||
Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.layout { measurable, constraints ->
|
||||
val child = measurable.measure(constraints)
|
||||
val lift = D.sheetSubLift.roundToPx()
|
||||
layout(child.width, (child.height + lift).coerceAtLeast(0)) { child.placeRelative(0, lift) }
|
||||
}.padding(bottom = D.sheetSubBottom))
|
||||
Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body, top = D.sheetSubLift, bottom = D.sheetSubBottom))
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -167,8 +163,8 @@ private fun HiddenReceipt(state: TitleState) {
|
|||
}
|
||||
Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = TitleTextStyles.body)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) }
|
||||
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, Modifier.sheetFocused(state.sheetIndex == 0), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
|
||||
TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, Modifier.sheetFocused(state.sheetIndex == 1), onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/** Logical remote focus must reveal its row even though Android focus stays on the modal owner. */
|
||||
@Composable
|
||||
internal fun Modifier.sheetFocused(focused: Boolean): Modifier {
|
||||
val request = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) request.bringIntoView() }
|
||||
return bringIntoViewRequester(request)
|
||||
}
|
||||
|
|
@ -65,8 +65,8 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
|||
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
|
||||
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape))
|
||||
Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(person.name, color = C.fg, style = D.castName, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
Text(person.character, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.cssLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ internal object TitleTextStyles {
|
|||
private val lineBoxes = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
|
||||
private fun TextStyle.withLineBoxes() = copy(lineHeightStyle = lineBoxes)
|
||||
|
||||
val displayDetail = WolfskinTokens.Type.displayDetail.withLineBoxes()
|
||||
val dLine = WolfskinTokens.Title.dLine.withLineBoxes()
|
||||
val progress = WolfskinTokens.Title.dLine.withLineBoxes()
|
||||
val castName = WolfskinTokens.Title.castName.withLineBoxes()
|
||||
val body = WolfskinTokens.Type.body.withLineBoxes()
|
||||
val caption = WolfskinTokens.Type.caption.withLineBoxes()
|
||||
val label = WolfskinTokens.Type.label.withLineBoxes()
|
||||
|
|
|
|||
|
|
@ -359,6 +359,7 @@ object WolfskinTokens {
|
|||
val plateTop = 7.0.dp
|
||||
val plateSide = 8.0.dp
|
||||
val plateBottom = 23.0.dp
|
||||
val plateInlineDescent = 3.3.dp
|
||||
val plateFloor = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
|
||||
const val plateStep = 0.25f
|
||||
const val plateGapEm = 0.64f
|
||||
|
|
@ -419,6 +420,7 @@ object WolfskinTokens {
|
|||
val overviewGap = 17.0.dp
|
||||
val factsGap = 11.0.dp
|
||||
val factsBottom = 7.0.dp
|
||||
val factsHeight = 19.0.dp
|
||||
val titleBottom = 8.0.dp
|
||||
val iconGap = 7.0.dp
|
||||
val iconSize = 16.0.dp
|
||||
|
|
@ -441,6 +443,7 @@ object WolfskinTokens {
|
|||
val sheetSubLift = -4.0.dp
|
||||
val sheetSubBottom = 7.0.dp
|
||||
val sheetLabelTop = 4.0.dp
|
||||
val sheetButtonTop = 6.0.dp
|
||||
val sheetRowSide = 13.0.dp
|
||||
val versionRowVertical = 10.0.dp
|
||||
val trackHeight = 42.0.dp
|
||||
|
|
@ -462,6 +465,9 @@ object WolfskinTokens {
|
|||
const val fallbackHue = 260.0f
|
||||
val skeletonKickerWidth = 210.0.dp
|
||||
val skeletonKickerHeight = 17.0.dp
|
||||
val skeletonTitleGap = 13.0.dp
|
||||
val skeletonFactsGap = 12.0.dp
|
||||
val skeletonOverviewGap = 15.0.dp
|
||||
val skeletonTitleWidth = 380.0.dp
|
||||
val skeletonTitleHeight = 46.0.dp
|
||||
val skeletonFactsWidth = 280.0.dp
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue