Some minor optimizations to improve UI speed (#593)

## Description
Many tiny changes that improve UI rendering speed and reduces
recompositions

Still plenty of room for improvement though.
This commit is contained in:
Ray 2025-12-29 15:36:05 -05:00 committed by GitHub
parent 622a99631b
commit 243e08b635
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 62 additions and 110 deletions

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
@ -17,6 +18,7 @@ import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration import kotlin.time.Duration
@Serializable @Serializable
@Stable
data class BaseItem( data class BaseItem(
val data: BaseItemDto, val data: BaseItemDto,
val useSeriesForPrimary: Boolean, val useSeriesForPrimary: Boolean,

View file

@ -8,22 +8,15 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.util.FocusPair
@Composable @Composable
fun <T> ItemRow( fun <T> ItemRow(
@ -39,13 +32,10 @@ fun <T> ItemRow(
onLongClick: () -> Unit, onLongClick: () -> Unit,
) -> Unit, ) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
horizontalPadding: Dp = 16.dp, horizontalPadding: Dp = 16.dp,
) { ) {
val state = rememberLazyListState() val state = rememberLazyListState()
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
var focusedIndex by remember { mutableIntStateOf(focusPair?.column ?: 0) }
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,
@ -62,23 +52,14 @@ fun <T> ItemRow(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRestorer(focusPair?.focusRequester ?: firstFocus), .focusRestorer(firstFocus),
) { ) {
itemsIndexed(items) { index, item -> itemsIndexed(items) { index, item ->
val cardModifier = val cardModifier =
if (index == 0 && focusPair == null) { if (index == 0) {
Modifier.focusRequester(firstFocus) Modifier.focusRequester(firstFocus)
} else { } else {
if (focusPair != null && focusPair.column == index) { Modifier
Modifier.focusRequester(focusPair.focusRequester)
} else {
Modifier
}
}.onFocusChanged {
if (it.isFocused) {
focusedIndex = index
}
cardOnFocus?.invoke(it.isFocused, index)
} }
cardContent.invoke( cardContent.invoke(
index, index,
@ -91,38 +72,3 @@ fun <T> ItemRow(
} }
} }
} }
@Composable
fun BannerItemRow(
title: String,
items: List<BaseItem?>,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
modifier: Modifier = Modifier,
focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
aspectRatioOverride: Float? = null,
) = ItemRow(
title = title,
items = items,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
modifier = modifier,
cardContent = { index, item, modifier, onClick, onLongClick ->
BannerCard(
name = title,
item = item,
aspectRatio =
aspectRatioOverride ?: item?.aspectRatio ?: AspectRatios.WIDE,
cornerText = item?.data?.indexNumber?.let { "E$it" },
played = item?.data?.userData?.played ?: false,
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
interactionSource = null,
)
},
focusPair = focusPair,
cardOnFocus = cardOnFocus,
)

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.components
import android.content.Context import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -21,7 +22,7 @@ fun MovieQuickDetails(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val now = LocalClock.current.now val now by LocalClock.current.now
val details = val details =
remember(dto, now) { remember(dto, now) {
buildList { buildList {

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -59,7 +60,7 @@ fun EpisodeQuickDetails(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val now = LocalClock.current.now val now by LocalClock.current.now
val details = val details =
remember(dto, now) { remember(dto, now) {
buildList { buildList {

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -13,8 +14,9 @@ import com.github.damontecres.wholphin.ui.util.LocalClock
@Composable @Composable
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) { fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
val timeString by LocalClock.current.timeString
Text( Text(
text = LocalClock.current.timeString, text = timeString,
fontSize = 18.sp, fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,

View file

@ -410,7 +410,7 @@ fun PlaylistItem(
}, },
trailingContent = { trailingContent = {
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration -> item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
val now = LocalClock.current.now val now by LocalClock.current.now
val endTimeStr = val endTimeStr =
remember(item, now) { remember(item, now) {
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)

View file

@ -66,7 +66,6 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
@ -226,14 +225,15 @@ fun HomePageContent(
var position by rememberSaveable(stateSaver = RowColumnSaver) { var position by rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(RowColumn(firstRow, 0)) mutableStateOf(RowColumn(firstRow, 0))
} }
var focusedItem = val focusedItem =
position.let { remember(position) {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) position.let {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
}
} }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val focusRequester = remember { FocusRequester() } val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } }
val positionFocusRequester = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
LaunchedEffect(homeRows) { LaunchedEffect(homeRows) {
if (!focused) { if (!focused) {
@ -241,7 +241,7 @@ fun HomePageContent(
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } .indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
.takeIf { it >= 0 } .takeIf { it >= 0 }
?.let { ?.let {
positionFocusRequester.tryRequestFocus() rowFocusRequesters[it].tryRequestFocus()
delay(50) delay(50)
listState.animateScrollToItem(position.row) listState.animateScrollToItem(position.row)
focused = true focused = true
@ -251,9 +251,6 @@ fun HomePageContent(
LaunchedEffect(position) { LaunchedEffect(position) {
listState.animateScrollToItem(position.row) listState.animateScrollToItem(position.row)
} }
LaunchedEffect(focusedItem) {
focusedItem?.let(onUpdateBackdrop)
}
Box(modifier = modifier) { Box(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader( HomePageHeader(
@ -275,16 +272,7 @@ fun HomePageContent(
), ),
modifier = modifier =
Modifier Modifier
.focusRestorer() .focusRestorer(),
.onKeyEvent {
val item = focusedItem
if (isPlayKeyUp(it) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(position, item)
return@onKeyEvent true
}
return@onKeyEvent false
},
) { ) {
itemsIndexed(homeRows) { rowIndex, row -> itemsIndexed(homeRows) { rowIndex, row ->
when (val r = row) { when (val r = row) {
@ -347,18 +335,13 @@ fun HomePageContent(
onClickItem = { index, item -> onClickItem = { index, item ->
onClickItem.invoke(RowColumn(rowIndex, index), item) onClickItem.invoke(RowColumn(rowIndex, index), item)
}, },
cardOnFocus = { isFocused, index ->
if (isFocused) {
focusedItem = row.items.getOrNull(index)
position = RowColumn(rowIndex, index)
}
},
onLongClickItem = { index, item -> onLongClickItem = { index, item ->
onLongClickItem.invoke(RowColumn(rowIndex, index), item) onLongClickItem.invoke(RowColumn(rowIndex, index), item)
}, },
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(rowFocusRequesters[rowIndex])
.animateItem(), .animateItem(),
cardContent = { index, item, cardModifier, onClick, onLongClick -> cardContent = { index, item, cardModifier, onClick, onLongClick ->
val cornerText = val cornerText =
@ -385,29 +368,32 @@ fun HomePageContent(
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = modifier =
cardModifier cardModifier
.ifElse( .onFocusChanged {
focusedItem == item,
Modifier.focusRequester(focusRequester),
).ifElse(
RowColumn(rowIndex, index) == position,
Modifier.focusRequester(
positionFocusRequester,
),
).onFocusChanged {
if (it.isFocused) { if (it.isFocused) {
position = RowColumn(rowIndex, index)
item?.let(onUpdateBackdrop)
}
if (it.isFocused && onFocusPosition != null) {
val nonEmptyRowBefore = val nonEmptyRowBefore =
homeRows homeRows
.subList(0, rowIndex) .subList(0, rowIndex)
.count { .count {
it is HomeRowLoadingState.Success && it.items.isEmpty() it is HomeRowLoadingState.Success && it.items.isEmpty()
} }
onFocusPosition?.invoke( onFocusPosition.invoke(
RowColumn( RowColumn(
rowIndex - nonEmptyRowBefore, rowIndex - nonEmptyRowBefore,
index, index,
), ),
) )
} }
}.onKeyEvent {
if (isPlayKeyUp(it) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(position, item)
return@onKeyEvent true
}
return@onKeyEvent false
}, },
interactionSource = null, interactionSource = null,
cardHeight = Cards.height2x3, cardHeight = Cards.height2x3,

View file

@ -4,7 +4,6 @@ import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
@ -37,11 +36,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
@ -346,10 +345,8 @@ fun NavDrawer(
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
.width(drawerWidth) .width(drawerWidth)
.background(drawerBackground) .drawBehind {
.onFocusChanged { drawRect(drawerBackground)
if (!it.hasFocus) {
}
}, },
) { ) {
// Even though some must be clicked, focusing on it should clear other focused items // Even though some must be clicked, focusing on it should clear other focused items

View file

@ -3,11 +3,10 @@ package com.github.damontecres.wholphin.ui.util
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.TimeFormatter
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -22,20 +21,28 @@ data class Clock(
/** /**
* The current [LocalDateTime] * The current [LocalDateTime]
*/ */
val now: LocalDateTime, val now: MutableState<LocalDateTime>,
/** /**
* The current time formatted as a string with [TimeFormatter] * The current time formatted as a string with [TimeFormatter]
*/ */
val timeString: String, val timeString: MutableState<String>,
) )
@Composable @Composable
fun ProvideLocalClock(content: @Composable () -> Unit) { fun ProvideLocalClock(content: @Composable () -> Unit) {
var clock by remember { mutableStateOf(LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) }) } val clock =
remember {
LocalDateTime
.now()
.let { Clock(mutableStateOf(it), mutableStateOf(TimeFormatter.format(it))) }
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (isActive) { while (isActive) {
clock = LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) } val now = LocalDateTime.now()
delay(1_000) val time = TimeFormatter.format(now)
clock.now.value = now
clock.timeString.value = time
delay(2_000)
} }
} }
CompositionLocalProvider(LocalClock provides clock, content) CompositionLocalProvider(LocalClock provides clock, content)

View file

@ -104,6 +104,16 @@ class TestStreamChoiceServiceBasic(
), ),
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED), itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED),
), ),
TestInput(
1,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = true),
subtitle(1, "eng", false),
subtitle(2, "eng", false),
),
),
) )
} }
} }