Several UI fixes (#1730)

## Description
- Fixes not scrolling to the last season on series overview if there is
a Specials season
- Apply the "bouncing" fix from #1543 to all applicable card types
- Fix card images not automatically reloading when changing image type
on grid pages
- Get rid of the sticky headers which overlap items on view options
dialogs and use a fixed header as elsewhere in the app

### Related issues
Related to #1708 which didn't introduce the bug, but made it much more
likely to occur

### Testing
Emulator & unit tests

## Screenshots
N/A, the sticky header UI changes are very minor

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-07-21 17:20:59 -04:00 • committed by GitHub
parent 2aae7eed38
commit 0a4ab7a547
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 159 additions and 130 deletions

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
@ -219,13 +218,11 @@ fun BannerCardWithTitle(
useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 0.dp else 8.dp)
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = cardHeight * aspectRationToUse
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.width(width),
) {
BannerCard(
@ -245,13 +242,7 @@ fun BannerCardWithTitle(
imageContentScale = imageContentScale,
useSeriesForPrimary = useSeriesForPrimary,
)
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
SlidingCardText(focused) {
Text(
text = title ?: "",
style = MaterialTheme.typography.bodyLarge,

View file

@ -59,8 +59,6 @@ fun DiscoverItemCard(
width: Dp = Cards.height2x3 * AspectRatios.TALL,
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
@ -78,7 +76,7 @@ fun DiscoverItemCard(
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spaceBetween),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.size(width, Dp.Unspecified),
) {
Card(
@ -180,13 +178,7 @@ fun DiscoverItemCard(
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
SlidingCardText(focused) {
Text(
text = item?.title ?: "",
maxLines = 1,

View file

@ -51,8 +51,6 @@ fun EpisodeCard(
) {
val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
@ -74,7 +72,7 @@ fun EpisodeCard(
val density = LocalDensity.current
val imageWidthPx = remember(imageWidth) { with(density) { imageWidth.roundToPx() } }
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.size(width, height),
) {
Card(
@ -126,13 +124,7 @@ fun EpisodeCard(
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
SlidingCardText(focused) {
Text(
text = dto?.seriesName ?: "",
maxLines = 1,

View file

@ -52,8 +52,6 @@ fun GridCard(
) {
val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
@ -71,7 +69,7 @@ fun GridCard(
}
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Card(
@ -107,13 +105,7 @@ fun GridCard(
)
}
AnimatedVisibility(showTitle) {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
SlidingCardText(focused) {
Text(
text = item?.title ?: "",
maxLines = 1,

View file

@ -67,7 +67,7 @@ fun ItemCardImage(
) {
val imageUrlService = LocalImageUrlService.current
val imageUrl =
remember(item) {
remember(item, imageType, fillWidth, fillHeight) {
item?.let {
imageUrlService.getItemImageUrl(
item,

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
@ -26,11 +24,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
@ -99,10 +95,6 @@ fun PersonCard(
focusedAfterDelay = false
}
// Do not use `by` here, this way we are Defer reads and recompositions to only when modifier calculates
val spaceBetweenState = animateDpAsState(if (focused) 12.dp else 4.dp, label = "spaceBetween")
val spaceBelowState = animateDpAsState(if (focused) 4.dp else 12.dp, label = "spaceBelow")
Column(
verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing
modifier = modifier,
@ -170,21 +162,7 @@ fun PersonCard(
.clip(CircleShape),
)
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
// Optimization: move animation reads to layout/draw phase
.offset {
IntOffset(0, (spaceBetweenState.value - 4.dp).roundToPx())
}.layout { measurable, constraints ->
val paddingPx = spaceBelowState.value.roundToPx()
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height + paddingPx) {
placeable.placeRelative(0, 0)
}
}.fillMaxWidth(),
) {
SlidingCardText(focused) {
Text(
text = name ?: "",
maxLines = 1,

View file

@ -0,0 +1,46 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Slides the [content] up/down as the card is [focused] or not
*
* This is used for the card's title, moving it down when focused as the card itself scales up
*/
@Composable
fun SlidingCardText(
focused: Boolean,
modifier: Modifier = Modifier,
spaceBetweenFocused: Dp = 4.dp,
spaceBetweenUnfocused: Dp = 12.dp,
content: @Composable ColumnScope.() -> Unit,
) {
val spaceBetween =
animateDpAsState(
if (focused) spaceBetweenFocused else spaceBetweenUnfocused,
label = "spaceBetween",
)
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
modifier
.layout { measurable, constraints ->
val topPaddingPx = (spaceBetweenUnfocused - spaceBetween.value).roundToPx()
val bottomPaddingPx = spaceBetween.value.roundToPx()
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height + bottomPaddingPx + topPaddingPx) {
placeable.placeRelative(0, topPaddingPx)
}
}.fillMaxWidth(),
content = content,
)
}

View file

@ -53,8 +53,6 @@ fun ViewMoreCard(
showTitle: Boolean = true,
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
@ -79,7 +77,7 @@ fun ViewMoreCard(
size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio))
}
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Card(
@ -108,14 +106,7 @@ fun ViewMoreCard(
}
}
if (showTitle) {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
Modifier
.width(width)
.padding(bottom = spaceBelow),
) {
SlidingCardText(focused) {
Text(
text = stringResource(R.string.view_more),
maxLines = 1,

View file

@ -4,8 +4,11 @@ import android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -19,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@ -74,45 +78,52 @@ fun ViewOptionsDialog(
ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS
}
}
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.width(256.dp)
.heightIn(max = 380.dp)
.focusRequester(focusRequester)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
shape = RoundedCornerShape(8.dp),
),
) {
stickyHeader {
Text(
text = stringResource(R.string.view_options),
style = MaterialTheme.typography.titleMedium,
)
}
items(options, key = { it.title }) { pref ->
pref as AppPreference<ViewOptions, Any>
val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(viewOptions)
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
onViewOptionsChange.invoke(pref.setter(viewOptions, newValue))
},
interactionSource = interactionSource,
modifier = Modifier,
onClickPreference = { pref ->
if (pref == ViewOptions.ViewOptionsReset) {
onViewOptionsChange.invoke(defaultViewOptions)
}
},
)
Text(
text = stringResource(R.string.view_options),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier =
Modifier
.padding(vertical = 8.dp)
.fillMaxWidth(),
)
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.focusRequester(focusRequester),
) {
items(options, key = { it.title }) { pref ->
pref as AppPreference<ViewOptions, Any>
val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(viewOptions)
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
onViewOptionsChange.invoke(pref.setter(viewOptions, newValue))
},
interactionSource = interactionSource,
modifier = Modifier,
onClickPreference = { pref ->
if (pref == ViewOptions.ViewOptionsReset) {
onViewOptionsChange.invoke(defaultViewOptions)
}
},
)
}
}
}
}

View file

@ -4,8 +4,11 @@ import android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -19,6 +22,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@ -54,40 +58,47 @@ fun LiveTvViewOptionsDialog(
window.setGravity(Gravity.END)
window.setDimAmount(0f)
}
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.width(256.dp)
.heightIn(max = 380.dp)
.focusRequester(focusRequester)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
shape = RoundedCornerShape(8.dp),
),
) {
stickyHeader {
Text(
text = stringResource(R.string.view_options),
style = MaterialTheme.typography.titleMedium,
)
}
items(liveTvPreferences) { pref ->
pref as AppPreference<AppPreferences, Any>
val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(preferences)
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
onViewOptionsChange.invoke(pref.setter(preferences, newValue))
},
interactionSource = interactionSource,
modifier = Modifier,
)
Text(
text = stringResource(R.string.view_options),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier =
Modifier
.padding(vertical = 8.dp)
.fillMaxWidth(),
)
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.focusRequester(focusRequester),
) {
items(liveTvPreferences) { pref ->
pref as AppPreference<AppPreferences, Any>
val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(preferences)
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
onViewOptionsChange.invoke(pref.setter(preferences, newValue))
},
interactionSource = interactionSource,
modifier = Modifier,
)
}
}
}
}

View file

@ -746,7 +746,7 @@ suspend fun findIndexByNumberOrId(
// Start searching from the target number and choose direction from there
val num = list.getBlocking(listIndex)?.indexNumber
if (num.lt(targetNum)) {
for (i in listIndex + 1 until list.lastIndex) {
for (i in listIndex + 1 until list.size) {
val item = list.getBlocking(i)
if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id)) {
return i

View file

@ -28,6 +28,7 @@ class TestFindIndexByNumberOrId {
val epS02E03 = create(3, 2)
val epS02E04 = create(4, 2)
val epS02E05 = create(5, 2)
val epS02E06 = create(6, 2)
val epS00E08 = create(8, 0)
val epS00E02 = create(2, 0)
@ -70,6 +71,30 @@ class TestFindIndexByNumberOrId {
}
}
@Test
fun `Test last index with zero start`() =
runTest {
val episodes =
BlockingList.of(
listOf(
create(0, 2),
epS02E01,
epS02E02,
epS02E03,
epS02E04,
epS02E05,
epS02E06,
),
)
findIndexByNumberOrId(
targetNum = 6,
targetId = epS02E06.id,
list = BlockingList.of(episodes),
).let { index ->
Assert.assertEquals(6, index)
}
}
@Test
fun `Test missing episodes`() =
runTest {