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

@ -84,5 +84,43 @@ class TitleAsyncFocusTest {
assertFalse(shell.navFocused) assertFalse(shell.navFocused)
} }
@Test fun loadingPersonFocusesItsFirstCreditBeforeRealRemoteNavigation() {
val id = UUID.fromString("00000000-0000-0000-0000-000000000013")
val reply = CompletableDeferred<org.justinvisser.wolfskin.tv.read.PersonRead>()
val facade = mockk<TitleReadFacade>()
coEvery { facade.readPersonSaved(id) } returns null
coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() }
viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true))
val viewer = ShellPerson("justin", "Justin", "J", false)
val route = personRoute(id.toString())
val shell = ShellState(listOf(viewer), viewer.key).apply {
pickerVisible = false
pickerLaunch = false
enterDestination(route)
}
rule.setContent { WolfskinTheme {
TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel)
} }
rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading }
rule.waitForIdle()
assertTrue(shell.navFocused)
press(Key.DirectionDown)
assertTrue(shell.navFocused)
rule.runOnIdle {
reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead(
org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"),
listOf(TitleItem("first-credit", "First credit", "movie"), TitleItem("second-credit", "Second credit", "movie")),
))
}
rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable }
assertNotNull("A real person reply must populate the performer destination", viewModel.person)
rule.waitForIdle()
assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused)
assertEquals(0, viewModel.person!!.index)
press(Key.DirectionRight)
assertEquals(1, viewModel.person!!.index)
assertFalse(shell.navFocused)
}
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } } private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
} }

View file

@ -118,6 +118,7 @@ class TitleCaptureTest {
File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2)) File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2))
} }
recordTextMetrics(directory, number) recordTextMetrics(directory, number)
recordElements(directory, number)
} }
private fun recordTextMetrics(directory: File, number: Int) { private fun recordTextMetrics(directory: File, number: Int) {
@ -143,6 +144,20 @@ class TitleCaptureTest {
File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2)) File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2))
} }
private fun recordElements(directory: File, number: Int) {
val tag = androidx.compose.ui.semantics.SemanticsProperties.TestTag
val description = androidx.compose.ui.semantics.SemanticsProperties.ContentDescription
val matcher = SemanticsMatcher.keyIsDefined(tag) or SemanticsMatcher.keyIsDefined(description)
val records = org.json.JSONArray()
rule.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
records.put(JSONObject()
.put("tag", if (node.config.contains(tag)) node.config[tag] else JSONObject.NULL)
.put("description", if (node.config.contains(description)) org.json.JSONArray(node.config[description]) else JSONObject.NULL)
.put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom))))
}
File(directory, "st-${number.toString().padStart(3, '0')}-elements.json").writeText(records.toString(2))
}
private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) } private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) }
private fun open(state: TitleState, shell: ShellState, id: String) { private fun open(state: TitleState, shell: ShellState, id: String) {
option(state, id) option(state, id)

View file

@ -300,5 +300,46 @@ class TitleKeysTest {
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2 val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f) assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun filmHeaderKeepsItsFixedCssStack() {
render()
assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f)
assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f)
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() {
render("Andor")
assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
}
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
render(transform = { data ->
val source = data.versions.first().copy(
audio = List(3) { org.justinvisser.wolfskin.design.TitleTrack(it + 3, "Audio ${it + 1}") },
subtitles = List(15) { org.justinvisser.wolfskin.design.TitleTrack(it + 20, "Subtitle ${it + 1}") },
)
data.copy(versions = listOf(source))
})
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "tracks" }) }
press(Key.DirectionCenter)
val bottom = rule.onRoot().fetchSemanticsNode().boundsInRoot.bottom
val labels = (1..3).map { "Audio $it" } + "Off" + (1..15).map { "Subtitle $it" } + "Done"
labels.forEachIndexed { index, label ->
if (index > 0) press(Key.DirectionDown)
rule.waitForIdle()
val bounds = rule.onNodeWithText(label, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertTrue("$label must have visible height: $bounds", bounds.height > 20)
assertTrue("$label stays above the viewport bottom: $bounds / $bottom", bounds.bottom <= bottom)
assertTrue("$label stays below the viewport top: $bounds", bounds.top >= 0)
}
press(Key.DirectionCenter)
assertNull(state.sheet)
assertFalse(shell.navFocused)
} }
} }

View file

@ -166,4 +166,15 @@ class TitleReadFacadeTest {
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Version(data.watchTarget, data.versions.single())) facade.apply(org.justinvisser.wolfskin.design.TitleAction.Version(data.watchTarget, data.versions.single()))
coVerify(exactly = 1) { playback.savePlayVersion(firstId, UUID.fromString("00000000-0000-0000-0000-000000000005")) } coVerify(exactly = 1) { playback.savePlayVersion(firstId, UUID.fromString("00000000-0000-0000-0000-000000000005")) }
} }
@Test fun subtitleOffDisablesPlaybackRatherThanRestoringThePreferredLanguage() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
coEvery { playback.saveTrackSelection(any(), any(), any(), MediaStreamType.SUBTITLE) } returns
com.github.damontecres.wholphin.data.model.ItemPlayback(userId = 1, itemId = firstId)
val data = facade.read(firstId, null, preferences)
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = -1))
coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.DISABLED, MediaStreamType.SUBTITLE) }
coVerify(exactly = 0) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.UNSPECIFIED, MediaStreamType.SUBTITLE) }
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = 8))
coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), 8, MediaStreamType.SUBTITLE) }
}
} }

View file

@ -4,6 +4,7 @@ import android.content.Context
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
@ -107,7 +108,7 @@ class TitleReadFacade @Inject constructor(
val item = BaseItem(api.userLibraryApi.getItem(id).content) val item = BaseItem(api.userLibraryApi.getItem(id).content)
var choice = repository.currentUser?.let { playback.itemPlaybackDao.getItem(it, id) } var choice = repository.currentUser?.let { playback.itemPlaybackDao.getItem(it, id) }
action.audio?.let { choice = playback.saveTrackSelection(item, choice, it, MediaStreamType.AUDIO) } action.audio?.let { choice = playback.saveTrackSelection(item, choice, it, MediaStreamType.AUDIO) }
playback.saveTrackSelection(item, choice, action.subtitle, MediaStreamType.SUBTITLE) playback.saveTrackSelection(item, choice, if (action.subtitle == -1) TrackIndex.DISABLED else action.subtitle, MediaStreamType.SUBTITLE)
} }
is TitleAction.Watched -> watched.setWatched(UUID.fromString(action.item.id), action.watched) is TitleAction.Watched -> watched.setWatched(UUID.fromString(action.item.id), action.watched)
else -> Unit else -> Unit

View file

@ -310,13 +310,15 @@ pretend those operations succeeded or fall back to Seerr.
`TitleCaptureTest` writes the 34 approved title/person/options frames plus native `TitleCaptureTest` writes the 34 approved title/person/options frames plus native
text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved
typeface ruling keeps platform Roboto, not the browser's DejaVu Sans: no per-title typeface ruling keeps platform Roboto, not the browser's DejaVu Sans: no per-title
compensation and no wrapping-parity claim. Untrimmed CSS line boxes are scoped to compensation and no wrapping-parity claim. Explicit CSS line boxes constrain title
title/person content and sheets; shared nav/library typography is unchanged. headers, sheets, cast and poster captions without changing shared type tokens.
`TitleKeysTest` replays real Android keys from title entry, including modal Back, Fractional stack placement rounds only the accumulated position, preventing a
held OK, nearest-row entry, remembered columns, pending seasons, wheel re-entry one-pixel-per-row drift. Focused controls share the canonical lift shadow.
and native media-info line-box pitch. `TitleAsyncFocusTest` delays the service reply `TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
through the production destination/view-model, then exercises loaded options and entry, remembered columns, long-sheet visibility and full media-table geometry.
hardware sheet Back through real focus handling. `TitleAsyncFocusTest` delays title and person replies through the real destination
and view-model, then exercises content focus and hardware Back. Subtitles Off
maps to the player's explicit disabled sentinel, not its unspecified default.
The approved current gate uses exact title-entry suffixes, not padded launch The approved current gate uses exact title-entry suffixes, not padded launch
paths. `title-press-paths.json` retains the full sequences and entry boundaries. paths. `title-press-paths.json` retains the full sequences and entry boundaries.

View file

@ -374,6 +374,7 @@
"plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"}, "plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"},
"plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"}, "plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"},
"plateBottom": {"$type":"dimension","px":46,"dp":23,"source":"index.html:162"}, "plateBottom": {"$type":"dimension","px":46,"dp":23,"source":"index.html:162"},
"plateInlineDescent": {"$type":"dimension","px":6.6,"dp":3.3,"source":"index.html:156,162 inline-flex baseline strut; proto-104 rplate 100.2px minus 14+33.6+46"},
"plateFloor": {"$type":"typography","px":24,"sp":12,"weight":700,"$value":{"fontWeight":700},"source":"index.html:802"}, "plateFloor": {"$type":"typography","px":24,"sp":12,"weight":700,"$value":{"fontWeight":700},"source":"index.html:802"},
"plateStep": {"$type":"number","$value":0.25,"source":"spec 4.8"}, "plateStep": {"$type":"number","$value":0.25,"source":"spec 4.8"},
"plateGapEm": {"$type":"number","$value":0.64,"source":"index.html:164"}, "plateGapEm": {"$type":"number","$value":0.64,"source":"index.html:164"},

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))) alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
} }
Column(modifier.graphicsLayer { this.alpha = alpha.value }) { 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) CaptionFacts(facts, accentFirstFact, accentLastFact)
} }
} }
@ -40,8 +40,8 @@ private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolea
content = { content = {
parts.forEachIndexed { index, part -> parts.forEachIndexed { index, part ->
val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast) val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast)
Text(part, color = if (accented) colour else muted, style = style, maxLines = 1, overflow = TextOverflow.Clip) 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) if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.cssLineBox(style))
} }
}, },
modifier = Modifier.fillMaxWidth().clipToBounds(), 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 @Composable
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } } val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
LaunchedEffect(state) { shell.navFocused = false }
Box(modifier.fillMaxSize().background(C.bg)) { Box(modifier.fillMaxSize().background(C.bg)) {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) { androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
ShellSurface(shell, handlesContentKeys = false, savedAt = state.savedAt) { PersonPage(state, shell, it) } 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.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer 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) { Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
ratings.forEach { rating -> ratings.forEach { rating ->
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) { 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) { val resource = when (rating.source) {
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh "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 "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 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), 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) 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") val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
BoxWithConstraints(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha } 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))) .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 width = with(density) { maxWidth.toPx() }
val fit = remember(ratings, width, density, Locale.getDefault()) { val fit = remember(ratings, width, density, Locale.getDefault()) {
var size = WolfskinTokens.Type.ratingPlate.fontSize.value var size = WolfskinTokens.Type.ratingPlate.fontSize.value
@ -99,7 +100,8 @@ fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifi
count to size count to size
} }
val fontSize = TextUnit(fit.second, TextUnitType.Sp) val fontSize = TextUnit(fit.second, TextUnitType.Sp)
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize, val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize,
lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value)), plate = true) 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) 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).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on } .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) .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), .clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { 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.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) { fun TitleRadio(selected: Boolean, focused: Boolean) {
val color = if (focused) C.onFocus else C.fg 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) 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)) 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 } if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
val item = state.data.item val item = state.data.item
val type = if (item.kind == "movie") "Movie" else "Series" val type = if (item.kind == "movie") "Movie" else "Series"
Column(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) { CssColumn(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) { 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)) Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
Text(buildString { Text(buildString {
append("$type · ${if (item.held) "In your library" else "Not in your library"}") append("$type · ${if (item.held) "In your library" else "Not in your library"}")
if (item.held && state.watched) append(" · Watched") if (item.held && state.watched) append(" · Watched")
if (!item.held && item.otherOwners.isNotEmpty()) append(" · In ${item.otherOwners.joinToString(" and ")}’s library") if (!item.held && item.otherOwners.isNotEmpty()) append(" · In ${item.otherOwners.joinToString(" and ")}’s library")
if (state.data.hidden) append(" · Hidden from your picks") 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")) 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.heightIn(min = L.iconCaptionLine + D.factsBottom).padding(bottom = D.factsBottom), horizontalArrangement = Arrangement.spacedBy(D.factsGap), verticalAlignment = Alignment.CenterVertically) { 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 = WolfskinTokens.Type.body) } 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 = WolfskinTokens.Type.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 = WolfskinTokens.Type.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 (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 -> state.data.requestedStage?.let { stage ->
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted } 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)) Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
Text(stage, color = color, style = WolfskinTokens.Type.label) Text(stage, color = color, style = TitleTextStyles.label)
Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = D.dLine) 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) { 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.", 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 -> 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) { if (target.resumeSeconds > 0) {
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) { 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 val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f
Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(C.accent)) 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) 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 = WolfskinTokens.Type.label) } 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 = WolfskinTokens.Type.label) 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, state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index,
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) }, Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
onFocus = { state.focus("header", index) }) { state.click("header", index) } } 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. */ /** A no-copy load has no focusable content and never draws a spinner. */
@Composable @Composable
fun TitleHeaderSkeleton(modifier: Modifier = Modifier) { 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))) 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))) 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))) 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))) 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)) { Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) 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))) } 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) val key = readShellKey(event)
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) { }.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), .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) { when (state.sheet) {
TitleSheet.Version -> VersionChoices(state) TitleSheet.Version -> VersionChoices(state)
TitleSheet.Tracks -> TrackChoices(state) TitleSheet.Tracks -> TrackChoices(state)
@ -70,24 +70,24 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
@Composable @Composable
private fun VersionChoices(state: TitleState) { 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") SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files")
state.data.versions.forEachIndexed { index, version -> 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) } 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 @Composable
private fun TrackChoices(state: TitleState) { private fun TrackChoices(state: TitleState) {
val version = state.version ?: return 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}") 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 -> version.audio.forEachIndexed { index, track ->
PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) } 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 val start = version.audio.size
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) } PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
version.subtitles.forEachIndexed { index, track -> 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) } PlaybackChoice(track.label, null, track.index == state.subtitleIndex, state.sheetIndex == position, { state.sheetIndex = position }) { state.clickSheet(position) }
} }
val done = start + version.subtitles.size + 1 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 @Composable
private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { 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 foreground = if (focused) C.onFocus else C.fg
val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical) 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)) .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide), .clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) { horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { CssColumn(Modifier.weight(1f)) {
Text(label, color = foreground, style = TitleTextStyles.listTitle) 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) if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption))
} }
TitleRadio(selected, focused) TitleRadio(selected, focused)
} }
@ -117,20 +117,20 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus
@Composable @Composable
private fun MediaInformation(state: TitleState) { private fun MediaInformation(state: TitleState) {
val version = state.version ?: return 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) 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("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("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("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 }) 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 @Composable
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) { private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop)) Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop))
Column(verticalArrangement = Arrangement.spacedBy(D.infoRowGap)) { values.forEach { (name, value) -> CssColumn(gap = D.infoRowGap) { values.forEach { (name, value) ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) { 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(name, color = C.dim, style = TitleTextStyles.infoKey, modifier = Modifier.width(D.infoLabelWidth))
Text(value, color = C.fg, style = TitleTextStyles.caption) Text(value, color = C.fg, style = TitleTextStyles.caption)
} }
@ -140,21 +140,17 @@ private fun MediaInfoSection(label: String, values: List<Pair<String, String>>)
@Composable @Composable
private fun WatchedConfirmation(state: TitleState) { private fun WatchedConfirmation(state: TitleState) {
val verb = if (state.watched) "unwatched" else "watched" 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}.") SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.")
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { Row(Modifier.padding(top = D.sheetButtonTop), 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("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, onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) } TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, Modifier.sheetFocused(state.sheetIndex == 1), onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) }
} }
} }
@Composable @Composable
private fun SheetSubtitle(text: String) { private fun SheetSubtitle(text: String) {
Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.layout { measurable, constraints -> Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body, top = D.sheetSubLift, bottom = D.sheetSubBottom))
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))
} }
@Composable @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) 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)) { Row(Modifier.padding(top = D.sheetButtonTop), 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("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, onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) } 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}")) { .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)) 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) { 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.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 = WolfskinTokens.Type.caption, 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 val lineBoxes = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
private fun TextStyle.withLineBoxes() = copy(lineHeightStyle = lineBoxes) 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 body = WolfskinTokens.Type.body.withLineBoxes()
val caption = WolfskinTokens.Type.caption.withLineBoxes() val caption = WolfskinTokens.Type.caption.withLineBoxes()
val label = WolfskinTokens.Type.label.withLineBoxes() val label = WolfskinTokens.Type.label.withLineBoxes()

View file

@ -359,6 +359,7 @@ object WolfskinTokens {
val plateTop = 7.0.dp val plateTop = 7.0.dp
val plateSide = 8.0.dp val plateSide = 8.0.dp
val plateBottom = 23.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) 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 plateStep = 0.25f
const val plateGapEm = 0.64f const val plateGapEm = 0.64f
@ -419,6 +420,7 @@ object WolfskinTokens {
val overviewGap = 17.0.dp val overviewGap = 17.0.dp
val factsGap = 11.0.dp val factsGap = 11.0.dp
val factsBottom = 7.0.dp val factsBottom = 7.0.dp
val factsHeight = 19.0.dp
val titleBottom = 8.0.dp val titleBottom = 8.0.dp
val iconGap = 7.0.dp val iconGap = 7.0.dp
val iconSize = 16.0.dp val iconSize = 16.0.dp
@ -441,6 +443,7 @@ object WolfskinTokens {
val sheetSubLift = -4.0.dp val sheetSubLift = -4.0.dp
val sheetSubBottom = 7.0.dp val sheetSubBottom = 7.0.dp
val sheetLabelTop = 4.0.dp val sheetLabelTop = 4.0.dp
val sheetButtonTop = 6.0.dp
val sheetRowSide = 13.0.dp val sheetRowSide = 13.0.dp
val versionRowVertical = 10.0.dp val versionRowVertical = 10.0.dp
val trackHeight = 42.0.dp val trackHeight = 42.0.dp
@ -462,6 +465,9 @@ object WolfskinTokens {
const val fallbackHue = 260.0f const val fallbackHue = 260.0f
val skeletonKickerWidth = 210.0.dp val skeletonKickerWidth = 210.0.dp
val skeletonKickerHeight = 17.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 skeletonTitleWidth = 380.0.dp
val skeletonTitleHeight = 46.0.dp val skeletonTitleHeight = 46.0.dp
val skeletonFactsWidth = 280.0.dp val skeletonFactsWidth = 280.0.dp

View file

@ -5,6 +5,7 @@
"overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"}, "overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"},
"factsGap": {"dp": 11, "px": 22, "source": "index.html:104"}, "factsGap": {"dp": 11, "px": 22, "source": "index.html:104"},
"factsBottom": {"dp": 7, "px": 14, "source": "index.html:104"}, "factsBottom": {"dp": 7, "px": 14, "source": "index.html:104"},
"factsHeight": {"dp": 19, "px": 38, "source": "index.html:104 .h-meta minimum height"},
"titleBottom": {"dp": 8, "px": 16, "source": "index.html:103"}, "titleBottom": {"dp": 8, "px": 16, "source": "index.html:103"},
"iconGap": {"dp": 7, "px": 14, "source": "index.html:108"}, "iconGap": {"dp": 7, "px": 14, "source": "index.html:108"},
"iconSize": {"dp": 16, "px": 32, "source": "index.html:275"}, "iconSize": {"dp": 16, "px": 32, "source": "index.html:275"},
@ -27,6 +28,7 @@
"sheetSubLift": {"dp": -4, "px": -8, "source": "index.html:229"}, "sheetSubLift": {"dp": -4, "px": -8, "source": "index.html:229"},
"sheetSubBottom": {"dp": 7, "px": 14, "source": "index.html:229"}, "sheetSubBottom": {"dp": 7, "px": 14, "source": "index.html:229"},
"sheetLabelTop": {"dp": 4, "px": 8, "source": "index.html:230"}, "sheetLabelTop": {"dp": 4, "px": 8, "source": "index.html:230"},
"sheetButtonTop": {"dp": 6, "px": 12, "source": "index.html:241 .sheet .btn margin-top"},
"sheetRowSide": {"dp": 13, "px": 26, "source": "index.html:231"}, "sheetRowSide": {"dp": 13, "px": 26, "source": "index.html:231"},
"versionRowVertical": {"dp": 10, "px": 20, "source": "index.html:1936"}, "versionRowVertical": {"dp": 10, "px": 20, "source": "index.html:1936"},
"trackHeight": {"dp": 42, "px": 84, "source": "index.html:1941"}, "trackHeight": {"dp": 42, "px": 84, "source": "index.html:1941"},
@ -48,6 +50,9 @@
"fallbackHue": {"$value": 260, "source": "index.html:130"}, "fallbackHue": {"$value": 260, "source": "index.html:130"},
"skeletonKickerWidth": {"dp": 210, "px": 420, "source": "index.html:1322"}, "skeletonKickerWidth": {"dp": 210, "px": 420, "source": "index.html:1322"},
"skeletonKickerHeight": {"dp": 17, "px": 34, "source": "index.html:1322"}, "skeletonKickerHeight": {"dp": 17, "px": 34, "source": "index.html:1322"},
"skeletonTitleGap": {"dp": 13, "px": 26, "source": "index.html:1322"},
"skeletonFactsGap": {"dp": 12, "px": 24, "source": "index.html:1322"},
"skeletonOverviewGap": {"dp": 15, "px": 30, "source": "index.html:1322"},
"skeletonTitleWidth": {"dp": 380, "px": 760, "source": "index.html:1322"}, "skeletonTitleWidth": {"dp": 380, "px": 760, "source": "index.html:1322"},
"skeletonTitleHeight": {"dp": 46, "px": 92, "source": "index.html:1322"}, "skeletonTitleHeight": {"dp": 46, "px": 92, "source": "index.html:1322"},
"skeletonFactsWidth": {"dp": 280, "px": 560, "source": "index.html:1322"}, "skeletonFactsWidth": {"dp": 280, "px": 560, "source": "index.html:1322"},

View file

@ -227,6 +227,7 @@
--library-plateTop: 14.0px; --library-plateTop: 14.0px;
--library-plateSide: 16.0px; --library-plateSide: 16.0px;
--library-plateBottom: 46.0px; --library-plateBottom: 46.0px;
--library-plateInlineDescent: 6.6px;
--library-plateFloor: 24.0px; --library-plateFloor: 24.0px;
--library-railWidth: 52.0px; --library-railWidth: 52.0px;
--library-railLetterHeight: 30.0px; --library-railLetterHeight: 30.0px;
@ -263,6 +264,7 @@
--title-overviewGap: 34.0px; --title-overviewGap: 34.0px;
--title-factsGap: 22.0px; --title-factsGap: 22.0px;
--title-factsBottom: 14.0px; --title-factsBottom: 14.0px;
--title-factsHeight: 38.0px;
--title-titleBottom: 16.0px; --title-titleBottom: 16.0px;
--title-iconGap: 14.0px; --title-iconGap: 14.0px;
--title-iconSize: 32.0px; --title-iconSize: 32.0px;
@ -285,6 +287,7 @@
--title-sheetSubLift: -8.0px; --title-sheetSubLift: -8.0px;
--title-sheetSubBottom: 14.0px; --title-sheetSubBottom: 14.0px;
--title-sheetLabelTop: 8.0px; --title-sheetLabelTop: 8.0px;
--title-sheetButtonTop: 12.0px;
--title-sheetRowSide: 26.0px; --title-sheetRowSide: 26.0px;
--title-versionRowVertical: 20.0px; --title-versionRowVertical: 20.0px;
--title-trackHeight: 84.0px; --title-trackHeight: 84.0px;
@ -305,6 +308,9 @@
--title-personInitialSize: 96.0px; --title-personInitialSize: 96.0px;
--title-skeletonKickerWidth: 420.0px; --title-skeletonKickerWidth: 420.0px;
--title-skeletonKickerHeight: 34.0px; --title-skeletonKickerHeight: 34.0px;
--title-skeletonTitleGap: 26.0px;
--title-skeletonFactsGap: 24.0px;
--title-skeletonOverviewGap: 30.0px;
--title-skeletonTitleWidth: 760.0px; --title-skeletonTitleWidth: 760.0px;
--title-skeletonTitleHeight: 92.0px; --title-skeletonTitleHeight: 92.0px;
--title-skeletonFactsWidth: 560.0px; --title-skeletonFactsWidth: 560.0px;

View file

@ -1,7 +1,7 @@
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json 1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json
407ec3f8cd78f4a59d037879ef066921fa0cf1c4ee81c14c8c2913d815c02305 generate_tokens.py 407ec3f8cd78f4a59d037879ef066921fa0cf1c4ee81c14c8c2913d815c02305 generate_tokens.py
276e8972052059732d19f2f19af6f10b4220b2f87237890a8f306f93f674a7a7 shell-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
4c429a7d5aac5c62f558d7091776b2fe94e3ad9ddc40df74798962543728574e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 9b2b4971e655c7acf3d6ce514696eb02415f55c933b5f6a5d62b96c3803ed4ef src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
bc82f477611669fbc6dd0d3e6559b1e05b0ad280531a883102803ffb0ebcdecb title-tokens.json 7ed093cc63b0fd6f47fe951da5df26ee7258865b8d0a51345a408ab64f037de0 title-tokens.json
a13b3dc369e8d5b1ca1d6de4f49daa8eafbeb822bf4984290f66e7c0e692d911 tokens.css 336cf853d492fedf7aa85e1d5aa6d01ecb7a92c2efca483bd490649910c145c5 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json