Preserve native title line boxes and async focus

This commit is contained in:
justinzeus 2026-11-05 20:26:43 +01:00
parent a8951827ba
commit 43ae716157
9 changed files with 187 additions and 32 deletions

View file

@ -104,7 +104,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier,
Row(Modifier.align(Alignment.TopEnd)
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2)
.height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))
.padding(horizontal = V.side), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
.padding(horizontal = V.side + V.border), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
Glyph("wifi", C.warn, Modifier.size(V.icon))
Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption)
}

View file

@ -131,15 +131,15 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
Row(Modifier.offset(L.safeSide, D.personTop), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
PersonPortrait(state.person, Modifier.size(L.personPortrait).clip(CircleShape), true)
Column(Modifier.width(L.contentWidth - L.personPortrait - D.personGap)) {
Text(state.person.name, color = C.fg, style = WolfskinTokens.Type.screenTitle, modifier = Modifier.padding(bottom = D.personNameBottom))
Text("Acting · ${state.credits.size} credit${if (state.credits.size == 1) "" else "s"} among this household’s titles", color = C.muted, style = WolfskinTokens.Type.body)
if (state.person.biography.isNotBlank()) Text(state.person.biography, color = C.dim, style = WolfskinTokens.Type.caption, maxLines = 3)
Text(state.person.name, color = C.fg, style = TitleTextStyles.screenTitle, modifier = Modifier.padding(bottom = D.personNameBottom))
Text("Acting · ${state.credits.size} credit${if (state.credits.size == 1) "" else "s"} among this household’s titles", color = C.muted, style = TitleTextStyles.body)
if (state.person.biography.isNotBlank()) Text(state.person.biography, color = C.dim, style = TitleTextStyles.caption, maxLines = 3)
}
}
}
}
item(key = "filmography", contentType = "header") {
Text("Filmography", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
}
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {

View file

@ -70,24 +70,24 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
@Composable
private fun VersionChoices(state: TitleState) {
Text("Version", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle)
SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files")
state.data.versions.forEachIndexed { index, version ->
PlaybackChoice(version.name, listOf(version.video, version.bitrate, version.size).filter { it.isNotBlank() }.joinToString(" · "), version == state.version, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
}
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = WolfskinTokens.Type.caption)
Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption)
}
@Composable
private fun TrackChoices(state: TitleState) {
val version = state.version ?: return
Text("Audio and subtitles", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle)
SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}")
Text("Audio", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop))
version.audio.forEachIndexed { index, track ->
PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
}
Text("Subtitles", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop))
val start = version.audio.size
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
version.subtitles.forEachIndexed { index, track ->
@ -107,8 +107,8 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus
.clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide),
horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(label, color = foreground, style = WolfskinTokens.Type.listTitle)
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = WolfskinTokens.Type.caption)
Text(label, color = foreground, style = TitleTextStyles.listTitle)
if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption)
}
TitleRadio(selected, focused)
}
@ -117,7 +117,7 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus
@Composable
private fun MediaInformation(state: TitleState) {
val version = state.version ?: return
Text("Media info", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle)
SheetSubtitle(state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name)
MediaInfoSection("General", listOf("Container" to version.container, "Size" to version.size.ifEmpty { "not known" }, "Bitrate" to version.bitrate, "Runtime" to (state.target?.runtime ?: "not known")))
MediaInfoSection("Video", listOf("Stream" to version.video))
@ -128,11 +128,11 @@ private fun MediaInformation(state: TitleState) {
@Composable
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
Text(label, color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop))
Column(verticalArrangement = Arrangement.spacedBy(D.infoRowGap)) { values.forEach { (name, value) ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
Text(name, color = C.dim, style = D.infoKey, modifier = Modifier.width(D.infoLabelWidth))
Text(value, color = C.fg, style = WolfskinTokens.Type.caption)
Text(name, color = C.dim, style = TitleTextStyles.infoKey, modifier = Modifier.width(D.infoLabelWidth))
Text(value, color = C.fg, style = TitleTextStyles.caption)
}
} }
}
@ -140,7 +140,7 @@ private fun MediaInfoSection(label: String, values: List<Pair<String, String>>)
@Composable
private fun WatchedConfirmation(state: TitleState) {
val verb = if (state.watched) "unwatched" else "watched"
Text("Mark the whole series as $verb?", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle)
SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.")
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }
@ -150,7 +150,7 @@ private fun WatchedConfirmation(state: TitleState) {
@Composable
private fun SheetSubtitle(text: String) {
Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.layout { measurable, constraints ->
Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.layout { measurable, constraints ->
val child = measurable.measure(constraints)
val lift = D.sheetSubLift.roundToPx()
layout(child.width, (child.height + lift).coerceAtLeast(0)) { child.placeRelative(0, lift) }
@ -163,9 +163,9 @@ private fun HiddenReceipt(state: TitleState) {
verticalArrangement = Arrangement.spacedBy(D.receiptGap)) {
Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("check", C.fg, Modifier.size(D.receiptIcon))
Text("Hidden from your picks", color = C.fg, style = D.receiptTitle)
Text("Hidden from your picks", color = C.fg, style = TitleTextStyles.receiptTitle)
}
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 = WolfskinTokens.Type.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)) {
TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) }

View file

@ -201,12 +201,13 @@ private fun TitleReadError(state: TitleState) {
Column(Modifier.offset(L.safeSide, D.errorTop).width(L.stateWidth)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(WolfskinTokens.SavedIndicator.border)
.padding(horizontal = D.errorSide, vertical = D.errorVertical)) {
Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("alert", C.warn, Modifier.size(D.receiptIcon))
Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = D.errorTitle)
Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = TitleTextStyles.errorTitle)
}
Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))
Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }, overArtwork = false) { state.click("header", index) } } }
}
}

View file

@ -0,0 +1,21 @@
package org.justinvisser.wolfskin.design
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
/** Explicit styles retain CSS leading without changing shared nav or library typography. */
internal object TitleTextStyles {
private val lineBoxes = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
private fun TextStyle.withLineBoxes() = copy(lineHeightStyle = lineBoxes)
val body = WolfskinTokens.Type.body.withLineBoxes()
val caption = WolfskinTokens.Type.caption.withLineBoxes()
val label = WolfskinTokens.Type.label.withLineBoxes()
val sheetTitle = WolfskinTokens.Type.sheetTitle.withLineBoxes()
val listTitle = WolfskinTokens.Type.listTitle.withLineBoxes()
val screenTitle = WolfskinTokens.Type.screenTitle.withLineBoxes()
val rowHeading = WolfskinTokens.Type.rowHeading.withLineBoxes()
val errorTitle = WolfskinTokens.Title.errorTitle.withLineBoxes()
val receiptTitle = WolfskinTokens.Title.receiptTitle.withLineBoxes()
val infoKey = WolfskinTokens.Title.infoKey.withLineBoxes()
}