Display size page polish: tick-slider control, typography lock, richer preview

Settings panel layout flipped: preview now occupies the left two-thirds
of the page (nav rail at the natural app-left position), settings panel
sits on the right at 300dp wide. Mirrors the real home-screen layout
so the preview reads as a true reflection of what the user will see.

Replaces the popup ChoicePreference selector with an inline tick-slider
per control. Each slider is a chunky 24dp rounded track using
surfaceVariant for the inactive background and primary for the thumb;
the thumb is a rounded pill that snaps to one of four slot centers and
fills 88% of the track height. D-pad LEFT/RIGHT cycles slots within the
focused control; UP/DOWN moves between controls. Selection commits on
focus change so the preview updates without an extra Enter press.

Wraps the settings panel in a nested MaterialTheme using the unscaled
AppTypography (both tv.material3 and compose.material3 flavors) so the
panel's own labels stay at base size regardless of the Text size level
the user picks. Without this nesting the panel chrome reflowed under
the user's fingers as they cycled levels.

Preview now uses SlimItemFields when fetching, so the Overview text
populates on the hero. Episodes row is rendered first (matching the
real home-screen ordering) so the hero defaults to an episode item,
which exercises the full hero surface (title + episode name + overview
+ quick details). TimeDisplay is drawn over the preview area when the
user's showClock preference is enabled.

Text-size jumps are tuned wider: ExtraSmall 84% (was 88), Small 92%
(was 94), Large 116% (was 108). Default stays at 100%. The hand-mapped
sp lookup is regenerated for these percentages.

scaledSp falls back to integer-rounded formula math instead of throwing
on unmapped base sp values, so the helper is safe to call from any
fontSize site without first extending the table.
This commit is contained in:
Justin Visser 2026-05-28 13:41:54 +02:00
parent ca8ed52cf0
commit 8ff97daf44
2 changed files with 174 additions and 82 deletions

View file

@ -19,17 +19,17 @@ fun DisplaySizeLevel.scaledSp(baseSp: Int): TextUnit {
when (this) {
DisplaySizeLevel.EXTRA_SMALL -> {
when (baseSp) {
10 -> 9
12 -> 11
10 -> 8
12 -> 10
13 -> 11
14 -> 12
16 -> 14
18 -> 16
20 -> 18
24 -> 21
28 -> 25
56 -> 49
64 -> 56
16 -> 13
18 -> 15
20 -> 17
24 -> 20
28 -> 24
56 -> 47
64 -> 54
else -> null
}
}
@ -42,28 +42,28 @@ fun DisplaySizeLevel.scaledSp(baseSp: Int): TextUnit {
14 -> 13
16 -> 15
18 -> 17
20 -> 19
24 -> 23
20 -> 18
24 -> 22
28 -> 26
56 -> 53
64 -> 60
56 -> 52
64 -> 59
else -> null
}
}
DisplaySizeLevel.LARGE -> {
when (baseSp) {
10 -> 11
12 -> 13
13 -> 14
14 -> 15
16 -> 17
18 -> 19
20 -> 22
24 -> 26
28 -> 30
56 -> 60
64 -> 69
10 -> 12
12 -> 14
13 -> 15
14 -> 16
16 -> 19
18 -> 21
20 -> 23
24 -> 28
28 -> 32
56 -> 65
64 -> 74
else -> null
}
}
@ -77,9 +77,9 @@ fun DisplaySizeLevel.scaledSp(baseSp: Int): TextUnit {
fun DisplaySizeLevel.textPercent(): Int =
when (this) {
DisplaySizeLevel.EXTRA_SMALL -> 88
DisplaySizeLevel.SMALL -> 94
DisplaySizeLevel.LARGE -> 108
DisplaySizeLevel.EXTRA_SMALL -> 84
DisplaySizeLevel.SMALL -> 92
DisplaySizeLevel.LARGE -> 116
DisplaySizeLevel.DEFAULT, DisplaySizeLevel.UNRECOGNIZED -> 100
}

View file

@ -1,6 +1,10 @@
package com.github.damontecres.wholphin.ui.preferences.displaysize
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -14,6 +18,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Favorite
@ -35,6 +40,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
@ -44,6 +53,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.contentColorFor
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppChoicePreference
@ -52,13 +62,18 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DisplaySizeLevel
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.handleDPadKeyEvents
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.settings.TitleText
import com.github.damontecres.wholphin.ui.main.settings.settingsWidth
import com.github.damontecres.wholphin.ui.preferences.ChoicePreference
import com.github.damontecres.wholphin.ui.preferences.PreferenceSummary
import com.github.damontecres.wholphin.ui.preferences.PreferenceTitle
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.theme.AppTypography
import com.github.damontecres.wholphin.ui.theme.toMaterialTypography
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.util.HomeRowLoadingState
@ -79,6 +94,7 @@ private const val PREVIEW_EPISODE_COUNT = 4
private val PREVIEW_NAV_RAIL_WIDTH_DP = 64.dp
private val PREVIEW_NAV_ITEM_HEIGHT_DP = 56.dp
private val PREVIEW_NAV_ICON_SIZE_DP = 24.dp
private val DisplaySizeSettingsWidth = 300.dp
private val PreviewNavRailIcons: List<ImageVector> =
listOf(
@ -127,6 +143,7 @@ class DisplaySizeViewModel
sortBy = listOf(ItemSortBy.DATE_CREATED),
sortOrder = listOf(SortOrder.DESCENDING),
limit = limit,
fields = SlimItemFields,
)
val result = api.itemsApi.getItems(request).content
val items = (result.items ?: emptyList()).map { BaseItem.from(it, api, false) }
@ -161,11 +178,32 @@ fun DisplaySizePage(
Box(
modifier =
Modifier
.width(settingsWidth)
.fillMaxHeight()
.weight(1f),
) {
DisplaySizePreview(
posters = posters,
episodes = episodes,
showLogos = iface.showLogos,
onUpdateBackdrop = previewViewModel::updateBackdrop,
)
if (iface.showClock) {
TimeDisplay()
}
}
Box(
modifier =
Modifier
.width(DisplaySizeSettingsWidth)
.fillMaxHeight()
.background(color = MaterialTheme.colorScheme.surface)
.padding(8.dp),
) {
// Lock the settings-panel chrome to base typography so its labels
// don't reflow under the user as they cycle Text size levels.
androidx.compose.material3.MaterialTheme(typography = AppTypography.toMaterialTypography()) {
MaterialTheme(typography = AppTypography) {
DisplaySizeSettingsColumn(
textLevel = iface.textSizeLevel,
cardLevel = iface.cardSizeLevel,
@ -193,19 +231,7 @@ fun DisplaySizePage(
},
)
}
Box(
modifier =
Modifier
.fillMaxHeight()
.weight(1f),
) {
DisplaySizePreview(
posters = posters,
episodes = episodes,
showLogos = iface.showLogos,
onUpdateBackdrop = previewViewModel::updateBackdrop,
)
}
}
}
}
@ -225,15 +251,15 @@ private fun DisplaySizeSettingsColumn(
Column(modifier = Modifier.fillMaxSize()) {
TitleText(stringResource(R.string.display_size))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
modifier =
Modifier
.fillMaxHeight()
.focusRestorer(firstFocus),
) {
item {
LevelChoicePreference(
LevelTickSlider(
preference = AppPreference.TextSize,
currentValue = textLevel,
onChange = onChangeTextLevel,
@ -241,14 +267,14 @@ private fun DisplaySizeSettingsColumn(
)
}
item {
LevelChoicePreference(
LevelTickSlider(
preference = AppPreference.CardSize,
currentValue = cardLevel,
onChange = onChangeCardLevel,
)
}
item {
LevelChoicePreference(
LevelTickSlider(
preference = AppPreference.Spacing,
currentValue = spacingLevel,
onChange = onChangeSpacingLevel,
@ -259,22 +285,88 @@ private fun DisplaySizeSettingsColumn(
}
@Composable
private fun LevelChoicePreference(
private fun LevelTickSlider(
preference: AppChoicePreference<AppPreferences, DisplaySizeLevel>,
currentValue: DisplaySizeLevel,
onChange: (DisplaySizeLevel) -> Unit,
modifier: Modifier = Modifier,
) {
val levelNames = stringArrayResource(preference.displayValues).toList()
val selectedIndex = preference.valueToIndex(currentValue)
ChoicePreference(
title = stringResource(preference.title),
summary = levelNames.getOrNull(selectedIndex),
possibleValues = levelNames,
selectedIndex = selectedIndex,
onValueChange = { onChange(preference.indexToValue(it)) },
modifier = modifier,
val labels = stringArrayResource(preference.displayValues).toList()
val tickCount = labels.size
val currentIndex = preference.valueToIndex(currentValue).coerceIn(0, tickCount - 1)
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
val background =
if (focused) MaterialTheme.colorScheme.inverseSurface else Color.Unspecified
val contentColor = contentColorFor(background)
val activeColor = MaterialTheme.colorScheme.primary
val inactiveColor = MaterialTheme.colorScheme.surfaceVariant
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.fillMaxWidth()
.background(background, shape = RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 10.dp)
.handleDPadKeyEvents(
onLeft = {
if (currentIndex > 0) {
onChange(preference.indexToValue(currentIndex - 1))
}
},
onRight = {
if (currentIndex < tickCount - 1) {
onChange(preference.indexToValue(currentIndex + 1))
}
},
).focusable(interactionSource = interactionSource),
) {
PreferenceTitle(stringResource(preference.title), color = contentColor)
ScrollbarTrack(
currentIndex = currentIndex,
tickCount = tickCount,
activeColor = activeColor,
inactiveColor = inactiveColor,
modifier =
Modifier
.fillMaxWidth()
.height(24.dp),
)
PreferenceSummary(labels.getOrNull(currentIndex), color = contentColor)
}
}
@Composable
private fun ScrollbarTrack(
currentIndex: Int,
tickCount: Int,
activeColor: Color,
inactiveColor: Color,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier) {
val trackRadius = size.height / 2f
drawRoundRect(
color = inactiveColor,
cornerRadius = CornerRadius(trackRadius, trackRadius),
)
if (tickCount <= 0) return@Canvas
val padding = size.height * 0.06f
val thumbHeight = size.height - padding * 2f
val thumbRadius = thumbHeight / 2f
val slotWidth = (size.width - padding * 2f) / tickCount
val thumbWidthIdeal = slotWidth * 0.9f
val thumbWidth = thumbWidthIdeal.coerceAtLeast(thumbHeight)
val slotCenter = padding + slotWidth * (currentIndex + 0.5f)
val thumbLeft = (slotCenter - thumbWidth / 2f).coerceIn(padding, size.width - padding - thumbWidth)
drawRoundRect(
color = activeColor,
topLeft = Offset(thumbLeft, padding),
size = Size(thumbWidth, thumbHeight),
cornerRadius = CornerRadius(thumbRadius, thumbRadius),
)
}
}
@Composable
@ -289,8 +381,8 @@ private fun DisplaySizePreview(
val previewRows =
remember(posters, episodes) {
listOf(
HomeRowLoadingState.Success(title = recentlyAddedTitle, items = posters),
HomeRowLoadingState.Success(title = nextUpTitle, items = episodes),
HomeRowLoadingState.Success(title = recentlyAddedTitle, items = posters),
)
}
var position by remember { mutableStateOf(RowColumn(0, 0)) }