Match title CSS geometry and fix modal focus and subtitle choices

This commit is contained in:
justinzeus 2026-11-05 21:38:46 +01:00
parent e243894a20
commit 2fdd459037
21 changed files with 297 additions and 74 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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