feat(tvseries): enhance episode focus handling and scrolling behavior

This commit is contained in:
2026-05-25 19:59:16 +02:00
parent 1dedc7ff0a
commit 3cd706d449
2 changed files with 80 additions and 23 deletions

View File

@@ -81,6 +81,9 @@ internal fun TvSeriesScreenContent(
val initialFocusSeason = series.seasons.firstOrNull { it.id == initialFocusSeasonId } ?: defaultSeason val initialFocusSeason = series.seasons.firstOrNull { it.id == initialFocusSeasonId } ?: defaultSeason
val initialFocusedEpisodeId = initialFocusSeason.focusTargetEpisodeId(focusedEpisodeId) val initialFocusedEpisodeId = initialFocusSeason.focusTargetEpisodeId(focusedEpisodeId)
val seasonTabFocusRequester = remember { FocusRequester() } val seasonTabFocusRequester = remember { FocusRequester() }
var requestedInitialEpisodeFocus by remember(series.id, focusedSeasonId, focusedEpisodeId) {
mutableStateOf(false)
}
val waitingForInitialEpisodes = initialFocusedEpisodeId == null && val waitingForInitialEpisodes = initialFocusedEpisodeId == null &&
initialFocusSeason.episodes.isEmpty() && initialFocusSeason.episodes.isEmpty() &&
initialFocusSeason.episodeCount > 0 initialFocusSeason.episodeCount > 0
@@ -127,6 +130,9 @@ internal fun TvSeriesScreenContent(
episodes = selectedSeason.episodes, episodes = selectedSeason.episodes,
onPlayEpisode = { onPlayEpisode(it.id) }, onPlayEpisode = { onPlayEpisode(it.id) },
focusedEpisodeId = initialFocusedEpisodeId, focusedEpisodeId = initialFocusedEpisodeId,
requestFocus = selectedSeason.id == initialFocusSeasonId && !requestedInitialEpisodeFocus,
onFocusRequested = { requestedInitialEpisodeFocus = true },
upFocusRequester = seasonTabFocusRequester,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -40,6 +41,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
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.focus.onFocusChanged
@@ -91,7 +93,14 @@ internal fun TvSeasonTabs(
TabRow( TabRow(
selectedTabIndex = selectedSeasonIndex, selectedTabIndex = selectedSeasonIndex,
modifier = modifier modifier = modifier
.focusRestorer() .then(
if (selectedItemFocusRequester != null) {
Modifier.focusRestorer(selectedItemFocusRequester)
} else {
Modifier.focusRestorer()
}
)
.focusGroup()
.fillMaxWidth() .fillMaxWidth()
.wrapContentWidth(Alignment.Start), .wrapContentWidth(Alignment.Start),
contentColor = MaterialTheme.colorScheme.onSurface contentColor = MaterialTheme.colorScheme.onSurface
@@ -141,36 +150,60 @@ internal fun TvEpisodeCarousel(
episodes: List<Episode>, episodes: List<Episode>,
onPlayEpisode: (Episode) -> Unit, onPlayEpisode: (Episode) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusedEpisodeId: UUID? = null focusedEpisodeId: UUID? = null,
requestFocus: Boolean = false,
onFocusRequested: () -> Unit = {},
upFocusRequester: FocusRequester? = null
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val focusedEpisodeFocusRequester = remember { FocusRequester() } val rowScopedFocusRequester = remember { FocusRequester() }
var rowFocusedEpisodeId by remember { mutableStateOf<UUID?>(null) }
var requestedFocusEpisodeId by remember { mutableStateOf<UUID?>(null) } var requestedFocusEpisodeId by remember { mutableStateOf<UUID?>(null) }
val focusedEpisodeIndex = focusedEpisodeId?.let { id ->
episodes.indexOfFirst { it.id == id }
} ?: -1
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }
val targetEpisodeIndex = when {
focusedEpisodeIndex >= 0 -> focusedEpisodeIndex
firstUnwatchedIndex >= 0 -> firstUnwatchedIndex
episodes.isNotEmpty() -> 0
else -> -1
}
val targetEpisodeId = episodes.getOrNull(targetEpisodeIndex)?.id
LaunchedEffect(episodes, focusedEpisodeId) { LaunchedEffect(episodes, targetEpisodeId) {
val focusedEpisodeIndex = focusedEpisodeId?.let { id -> if (episodes.none { it.id == rowFocusedEpisodeId }) {
episodes.indexOfFirst { it.id == id } rowFocusedEpisodeId = targetEpisodeId
} ?: -1 }
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it }
val targetIndex = focusedEpisodeIndex.takeIf { it >= 0 } ?: firstUnwatchedIndex
if (targetIndex != 0) { if (targetEpisodeIndex > 0) {
listState.scrollToItem(targetIndex) listState.scrollToItem(targetEpisodeIndex)
} else { } else {
listState.scrollToItem(0) listState.scrollToItem(0)
} }
}
if (focusedEpisodeIndex >= 0 && requestedFocusEpisodeId != focusedEpisodeId) { LaunchedEffect(requestFocus, targetEpisodeId) {
if (requestFocus && targetEpisodeId != null && requestedFocusEpisodeId != targetEpisodeId) {
rowFocusedEpisodeId = targetEpisodeId
if (targetEpisodeIndex > 0) {
listState.scrollToItem(targetEpisodeIndex)
} else {
listState.scrollToItem(0)
}
withFrameNanos { } withFrameNanos { }
focusedEpisodeFocusRequester.requestFocus() rowScopedFocusRequester.requestFocus()
requestedFocusEpisodeId = focusedEpisodeId requestedFocusEpisodeId = targetEpisodeId
onFocusRequested()
} }
} }
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) { CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
LazyRow( LazyRow(
state = listState, state = listState,
modifier = modifier, modifier = modifier
.focusRestorer(rowScopedFocusRequester)
.focusGroup(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
@@ -178,13 +211,27 @@ internal fun TvEpisodeCarousel(
TvEpisodeCard( TvEpisodeCard(
episode = episode, episode = episode,
onPlayEpisode = { onPlayEpisode(episode) }, onPlayEpisode = { onPlayEpisode(episode) },
modifier = if (episode.id == focusedEpisodeId) { onFocused = { rowFocusedEpisodeId = episode.id },
Modifier modifier = Modifier
.focusRequester(focusedEpisodeFocusRequester) .then(
.testTag(SeriesNextUpEpisodeCardTag) if (episode.id == rowFocusedEpisodeId) {
} else { Modifier.focusRequester(rowScopedFocusRequester)
Modifier } else {
} Modifier
}
)
.then(
upFocusRequester?.let { requester ->
Modifier.focusProperties { up = requester }
} ?: Modifier
)
.then(
if (episode.id == targetEpisodeId) {
Modifier.testTag(SeriesNextUpEpisodeCardTag)
} else {
Modifier
}
)
) )
} }
} }
@@ -239,6 +286,7 @@ internal fun TvSeriesHeroSection(
private fun TvEpisodeCard( private fun TvEpisodeCard(
episode: Episode, episode: Episode,
onPlayEpisode: () -> Unit, onPlayEpisode: () -> Unit,
onFocused: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -250,7 +298,10 @@ private fun TvEpisodeCard(
modifier = modifier modifier = modifier
.width(260.dp) .width(260.dp)
.graphicsLayer { scaleX = scale; scaleY = scale } .graphicsLayer { scaleX = scale; scaleY = scale }
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) onFocused()
}
.clickable { onPlayEpisode() }, .clickable { onPlayEpisode() },
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {