Match title CSS geometry and fix modal focus and subtitle choices
This commit is contained in:
parent
e243894a20
commit
2fdd459037
21 changed files with 297 additions and 74 deletions
|
|
@ -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) } }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.FirstBaseline
|
||||||
|
import androidx.compose.ui.layout.LastBaseline
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.layout.LayoutModifier
|
||||||
|
import androidx.compose.ui.layout.Measurable
|
||||||
|
import androidx.compose.ui.layout.MeasureScope
|
||||||
|
import androidx.compose.ui.layout.ParentDataModifier
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** CSS leading constrains the line box, even when the platform font's natural box is taller. */
|
||||||
|
internal fun Modifier.cssLineBox(style: TextStyle, top: Dp = 0.dp, bottom: Dp = 0.dp): Modifier =
|
||||||
|
then(CssLineBox(style.lineHeight, top, bottom))
|
||||||
|
|
||||||
|
private data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier {
|
||||||
|
override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssLineBox
|
||||||
|
fun Density.exactHeight(height: Int): Float {
|
||||||
|
val margins = top.toPx() + bottom.toPx()
|
||||||
|
return ((height - margins) / lineHeight.toPx()).roundToInt().coerceAtLeast(1) * lineHeight.toPx() + margins
|
||||||
|
}
|
||||||
|
override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints) = with(measurable.measure(constraints.copy(minHeight = 0))) {
|
||||||
|
val line = lineHeight.toPx()
|
||||||
|
val lines = ((this[LastBaseline] - this[FirstBaseline]) / line).roundToInt().coerceAtLeast(0) + 1
|
||||||
|
val textHeight = lines * line
|
||||||
|
val offset = top.toPx() + (textHeight - height) / 2f
|
||||||
|
layout(width, (textHeight + top.toPx() + bottom.toPx()).roundToInt()) {
|
||||||
|
placeRelative(0, offset.roundToInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CssColumnHeight : ParentDataModifier {
|
||||||
|
var pixels = 0f
|
||||||
|
override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssColumnHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
|
||||||
|
@Composable
|
||||||
|
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = 0.dp, content: @Composable () -> Unit) {
|
||||||
|
val exact = remember { CssColumnHeight() }
|
||||||
|
Layout(content, modifier.then(exact)) { children, constraints ->
|
||||||
|
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }
|
||||||
|
fun height(index: Int): Float = when (val data = children[index].parentData) {
|
||||||
|
is CssLineBox -> with(data) { exactHeight(measured[index].height) }
|
||||||
|
is CssColumnHeight -> data.pixels
|
||||||
|
else -> measured[index].height.toFloat()
|
||||||
|
}
|
||||||
|
val space = gap.toPx()
|
||||||
|
var total = 0f
|
||||||
|
var width = constraints.minWidth
|
||||||
|
for (index in measured.indices) {
|
||||||
|
total += height(index) + if (index == measured.lastIndex) 0f else space
|
||||||
|
width = maxOf(width, measured[index].width)
|
||||||
|
}
|
||||||
|
exact.pixels = total
|
||||||
|
layout(width.coerceIn(constraints.minWidth, constraints.maxWidth), total.roundToInt().coerceIn(constraints.minHeight, constraints.maxHeight)) {
|
||||||
|
var y = 0f
|
||||||
|
for (index in measured.indices) {
|
||||||
|
measured[index].placeRelative(0, y.roundToInt())
|
||||||
|
y += height(index) + space
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,7 @@ class PersonState(initialPerson: TitlePerson, initialCredits: List<TitleItem>, p
|
||||||
@Composable
|
@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) }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))) }
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
/** Logical remote focus must reveal its row even though Android focus stays on the modal owner. */
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.sheetFocused(focused: Boolean): Modifier {
|
||||||
|
val request = remember { BringIntoViewRequester() }
|
||||||
|
LaunchedEffect(focused) { if (focused) request.bringIntoView() }
|
||||||
|
return bringIntoViewRequester(request)
|
||||||
|
}
|
||||||
|
|
@ -65,8 +65,8 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue