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
import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.formatDateTime
@ -17,6 +18,7 @@ import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
@Serializable
@Stable
data class BaseItem(
val data: BaseItemDto,
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.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.focus.onFocusChanged
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
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
fun <T> ItemRow(
@ -39,13 +32,10 @@ fun <T> ItemRow(
onLongClick: () -> Unit,
) -> Unit,
modifier: Modifier = Modifier,
focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
horizontalPadding: Dp = 16.dp,
) {
val state = rememberLazyListState()
val firstFocus = remember { FocusRequester() }
var focusedIndex by remember { mutableIntStateOf(focusPair?.column ?: 0) }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
@ -62,23 +52,14 @@ fun <T> ItemRow(
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(focusPair?.focusRequester ?: firstFocus),
.focusRestorer(firstFocus),
) {
itemsIndexed(items) { index, item ->
val cardModifier =
if (index == 0 && focusPair == null) {
if (index == 0) {
Modifier.focusRequester(firstFocus)
} else {
if (focusPair != null && focusPair.column == index) {
Modifier.focusRequester(focusPair.focusRequester)
} else {
Modifier
}
}.onFocusChanged {
if (it.isFocused) {
focusedIndex = index
}
cardOnFocus?.invoke(it.isFocused, index)
Modifier
}
cardContent.invoke(
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 androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -21,7 +22,7 @@ fun MovieQuickDetails(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val now = LocalClock.current.now
val now by LocalClock.current.now
val details =
remember(dto, now) {
buildList {

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -59,7 +60,7 @@ fun EpisodeQuickDetails(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val now = LocalClock.current.now
val now by LocalClock.current.now
val details =
remember(dto, now) {
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.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@ -13,8 +14,9 @@ import com.github.damontecres.wholphin.ui.util.LocalClock
@Composable
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
val timeString by LocalClock.current.timeString
Text(
text = LocalClock.current.timeString,
text = timeString,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,

View file

@ -410,7 +410,7 @@ fun PlaylistItem(
},
trailingContent = {
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
val now = LocalClock.current.now
val now by LocalClock.current.now
val endTimeStr =
remember(item, now) {
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.PlaylistLoadingState
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.nav.Destination
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
@ -226,14 +225,15 @@ fun HomePageContent(
var position by rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(RowColumn(firstRow, 0))
}
var focusedItem =
position.let {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
val focusedItem =
remember(position) {
position.let {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
}
}
val listState = rememberLazyListState()
val focusRequester = remember { FocusRequester() }
val positionFocusRequester = remember { FocusRequester() }
val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } }
var focused by remember { mutableStateOf(false) }
LaunchedEffect(homeRows) {
if (!focused) {
@ -241,7 +241,7 @@ fun HomePageContent(
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
.takeIf { it >= 0 }
?.let {
positionFocusRequester.tryRequestFocus()
rowFocusRequesters[it].tryRequestFocus()
delay(50)
listState.animateScrollToItem(position.row)
focused = true
@ -251,9 +251,6 @@ fun HomePageContent(
LaunchedEffect(position) {
listState.animateScrollToItem(position.row)
}
LaunchedEffect(focusedItem) {
focusedItem?.let(onUpdateBackdrop)
}
Box(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader(
@ -275,16 +272,7 @@ fun HomePageContent(
),
modifier =
Modifier
.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
},
.focusRestorer(),
) {
itemsIndexed(homeRows) { rowIndex, row ->
when (val r = row) {
@ -347,18 +335,13 @@ fun HomePageContent(
onClickItem = { 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.invoke(RowColumn(rowIndex, index), item)
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(rowFocusRequesters[rowIndex])
.animateItem(),
cardContent = { index, item, cardModifier, onClick, onLongClick ->
val cornerText =
@ -385,29 +368,32 @@ fun HomePageContent(
onLongClick = onLongClick,
modifier =
cardModifier
.ifElse(
focusedItem == item,
Modifier.focusRequester(focusRequester),
).ifElse(
RowColumn(rowIndex, index) == position,
Modifier.focusRequester(
positionFocusRequester,
),
).onFocusChanged {
.onFocusChanged {
if (it.isFocused) {
position = RowColumn(rowIndex, index)
item?.let(onUpdateBackdrop)
}
if (it.isFocused && onFocusPosition != null) {
val nonEmptyRowBefore =
homeRows
.subList(0, rowIndex)
.count {
it is HomeRowLoadingState.Success && it.items.isEmpty()
}
onFocusPosition?.invoke(
onFocusPosition.invoke(
RowColumn(
rowIndex - nonEmptyRowBefore,
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,
cardHeight = Cards.height2x3,

View file

@ -4,7 +4,6 @@ import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
@ -37,11 +36,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
@ -346,10 +345,8 @@ fun NavDrawer(
Modifier
.fillMaxHeight()
.width(drawerWidth)
.background(drawerBackground)
.onFocusChanged {
if (!it.hasFocus) {
}
.drawBehind {
drawRect(drawerBackground)
},
) {
// 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.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.github.damontecres.wholphin.ui.TimeFormatter
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@ -22,20 +21,28 @@ data class Clock(
/**
* The current [LocalDateTime]
*/
val now: LocalDateTime,
val now: MutableState<LocalDateTime>,
/**
* The current time formatted as a string with [TimeFormatter]
*/
val timeString: String,
val timeString: MutableState<String>,
)
@Composable
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) {
while (isActive) {
clock = LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) }
delay(1_000)
val now = LocalDateTime.now()
val time = TimeFormatter.format(now)
clock.now.value = now
clock.timeString.value = time
delay(2_000)
}
}
CompositionLocalProvider(LocalClock provides clock, content)

View file

@ -104,6 +104,16 @@ class TestStreamChoiceServiceBasic(
),
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),
),
),
)
}
}