Fix TV home next up focus navigation

This commit is contained in:
2026-03-28 21:15:26 +01:00
parent 6de42dc65b
commit fb4b8a56fa
3 changed files with 164 additions and 18 deletions

View File

@@ -20,6 +20,9 @@ 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.clip import androidx.compose.ui.draw.clip
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.focus.onFocusChanged
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -41,6 +44,9 @@ import org.jellyfin.sdk.model.api.BaseItemKind
fun PosterCard( fun PosterCard(
item: PosterItem, item: PosterItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusRequester: FocusRequester? = null,
upFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null,
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
@@ -70,8 +76,22 @@ fun PosterCard(
.data(item.imageUrl) .data(item.imageUrl)
.size(with(density) { posterWidth.roundToPx() }, with(density) { posterHeight.roundToPx() }) .size(with(density) { posterWidth.roundToPx() }, with(density) { posterHeight.roundToPx() })
.build() .build()
val imageFocusModifier = Modifier
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.then(
if (upFocusRequester != null || downFocusRequester != null) {
Modifier.focusProperties {
upFocusRequester?.let { up = it }
downFocusRequester?.let { down = it }
}
} else {
Modifier
}
)
Column( Column(
modifier = Modifier modifier = modifier
.width(posterWidth) .width(posterWidth)
.graphicsLayer { scaleX = scale; scaleY = scale } .graphicsLayer { scaleX = scale; scaleY = scale }
) { ) {
@@ -79,7 +99,7 @@ fun PosterCard(
PurefinAsyncImage( PurefinAsyncImage(
model = imageRequest, model = imageRequest,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = imageFocusModifier
.aspectRatio(2f / 3f) .aspectRatio(2f / 3f)
.clip(RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp))
.border( .border(

View File

@@ -8,7 +8,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.shared.home.LibraryItem
@@ -27,6 +30,24 @@ fun TvHomeContent(
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val visibleLibraries = remember(libraries, libraryContent) {
libraries.filter { libraryContent[it.id]?.isEmpty() != true }
}
val continueWatchingFocusRequester = remember { FocusRequester() }
val nextUpFocusRequester = remember { FocusRequester() }
val libraryFocusRequesters = remember(visibleLibraries.map { it.id }) {
visibleLibraries.associate { it.id to FocusRequester() }
}
val firstSectionFocusRequester = when {
continueWatching.isNotEmpty() -> continueWatchingFocusRequester
nextUp.isNotEmpty() -> nextUpFocusRequester
else -> visibleLibraries.firstOrNull()?.let { libraryFocusRequesters[it.id] }
}
LaunchedEffect(firstSectionFocusRequester) {
firstSectionFocusRequester?.requestFocus()
}
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -38,6 +59,11 @@ fun TvHomeContent(
item { item {
TvContinueWatchingSection( TvContinueWatchingSection(
items = continueWatching, items = continueWatching,
firstItemFocusRequester = continueWatchingFocusRequester,
downFocusRequester = when {
nextUp.isNotEmpty() -> nextUpFocusRequester
else -> visibleLibraries.firstOrNull()?.let { libraryFocusRequesters[it.id] }
},
onMovieSelected = onMovieSelected, onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected onEpisodeSelected = onEpisodeSelected
) )
@@ -48,6 +74,9 @@ fun TvHomeContent(
item { item {
TvNextUpSection( TvNextUpSection(
items = nextUp, items = nextUp,
firstItemFocusRequester = nextUpFocusRequester,
upFocusRequester = continueWatching.takeIf { it.isNotEmpty() }?.let { continueWatchingFocusRequester },
downFocusRequester = visibleLibraries.firstOrNull()?.let { libraryFocusRequesters[it.id] },
onEpisodeSelected = onEpisodeSelected onEpisodeSelected = onEpisodeSelected
) )
} }
@@ -55,13 +84,32 @@ fun TvHomeContent(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
items( items(
items = libraries.filter { libraryContent[it.id]?.isEmpty() != true }, items = visibleLibraries,
key = { it.id } key = { it.id }
) { item -> ) { item ->
val libraryIndex = visibleLibraries.indexOfFirst { it.id == item.id }
val previousLibraryFocusRequester = visibleLibraries
.getOrNull(libraryIndex - 1)
?.let { libraryFocusRequesters[it.id] }
val nextLibraryFocusRequester = visibleLibraries
.getOrNull(libraryIndex + 1)
?.let { libraryFocusRequesters[it.id] }
TvLibraryPosterSection( TvLibraryPosterSection(
title = item.name, title = item.name,
items = libraryContent[item.id] ?: emptyList(), items = libraryContent[item.id] ?: emptyList(),
action = "See All", action = "See All",
firstItemFocusRequester = libraryFocusRequesters[item.id],
upFocusRequester = if (libraryIndex == 0) {
when {
nextUp.isNotEmpty() -> nextUpFocusRequester
continueWatching.isNotEmpty() -> continueWatchingFocusRequester
else -> null
}
} else {
previousLibraryFocusRequester
},
downFocusRequester = nextLibraryFocusRequester,
onMovieSelected = onMovieSelected, onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected, onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected onEpisodeSelected = onEpisodeSelected

View File

@@ -16,13 +16,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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
@@ -31,6 +29,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.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
@@ -55,16 +54,13 @@ import kotlin.math.nextUp
@Composable @Composable
fun TvContinueWatchingSection( fun TvContinueWatchingSection(
items: List<ContinueWatchingItem>, items: List<ContinueWatchingItem>,
firstItemFocusRequester: FocusRequester? = null,
upFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null,
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val firstItemFocusRequester = remember { FocusRequester() }
LaunchedEffect(items.isNotEmpty()) {
if (items.isNotEmpty()) {
firstItemFocusRequester.requestFocus()
}
}
if (items.isEmpty()) return if (items.isEmpty()) return
TvSectionHeader( TvSectionHeader(
title = "Continue Watching", title = "Continue Watching",
@@ -79,6 +75,10 @@ fun TvContinueWatchingSection(
TvContinueWatchingCard( TvContinueWatchingCard(
item = item, item = item,
focusRequester = if (index == 0) firstItemFocusRequester else null, focusRequester = if (index == 0) firstItemFocusRequester else null,
isFirstItem = index == 0,
isLastItem = index == items.lastIndex,
upFocusRequester = upFocusRequester,
downFocusRequester = downFocusRequester,
onMovieSelected = onMovieSelected, onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected onEpisodeSelected = onEpisodeSelected
) )
@@ -91,6 +91,10 @@ fun TvContinueWatchingCard(
item: ContinueWatchingItem, item: ContinueWatchingItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusRequester: FocusRequester? = null, focusRequester: FocusRequester? = null,
isFirstItem: Boolean = false,
isLastItem: Boolean = false,
upFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null,
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) { ) {
@@ -127,6 +131,32 @@ fun TvContinueWatchingCard(
.size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() }) .size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() })
.build() .build()
val imageFocusModifier = Modifier
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.then(
if (upFocusRequester != null || downFocusRequester != null) {
Modifier.focusProperties {
upFocusRequester?.let { up = it }
downFocusRequester?.let { down = it }
if (isFirstItem) {
left = FocusRequester.Cancel
}
if (isLastItem) {
right = FocusRequester.Cancel
}
}
} else {
Modifier.focusProperties {
if (isFirstItem) {
left = FocusRequester.Cancel
}
if (isLastItem) {
right = FocusRequester.Cancel
}
}
}
)
Column( Column(
modifier = modifier modifier = modifier
.width(cardWidth) .width(cardWidth)
@@ -148,9 +178,8 @@ fun TvContinueWatchingCard(
PurefinAsyncImage( PurefinAsyncImage(
model = imageRequest, model = imageRequest,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = imageFocusModifier
.fillMaxSize() .fillMaxSize()
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.clickable { .clickable {
openItem(item) openItem(item)
@@ -188,6 +217,9 @@ fun TvContinueWatchingCard(
@Composable @Composable
fun TvNextUpSection( fun TvNextUpSection(
items: List<NextUpItem>, items: List<NextUpItem>,
firstItemFocusRequester: FocusRequester? = null,
upFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -201,10 +233,17 @@ fun TvNextUpSection(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items( itemsIndexed(
items = items, key = { it.id }) { item -> items = items,
key = { _, item -> item.id }
) { index, item ->
TvNextUpCard( TvNextUpCard(
item = item, item = item,
focusRequester = if (index == 0) firstItemFocusRequester else null,
isFirstItem = index == 0,
isLastItem = index == items.lastIndex,
upFocusRequester = upFocusRequester,
downFocusRequester = downFocusRequester,
onEpisodeSelected = onEpisodeSelected onEpisodeSelected = onEpisodeSelected
) )
} }
@@ -215,6 +254,11 @@ fun TvNextUpSection(
fun TvNextUpCard( fun TvNextUpCard(
item: NextUpItem, item: NextUpItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
focusRequester: FocusRequester? = null,
isFirstItem: Boolean = false,
isLastItem: Boolean = false,
upFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -239,6 +283,32 @@ fun TvNextUpCard(
.size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() }) .size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() })
.build() .build()
val imageFocusModifier = Modifier
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.then(
if (upFocusRequester != null || downFocusRequester != null) {
Modifier.focusProperties {
upFocusRequester?.let { up = it }
downFocusRequester?.let { down = it }
if (isFirstItem) {
left = FocusRequester.Cancel
}
if (isLastItem) {
right = FocusRequester.Cancel
}
}
} else {
Modifier.focusProperties {
if (isFirstItem) {
left = FocusRequester.Cancel
}
if (isLastItem) {
right = FocusRequester.Cancel
}
}
}
)
Column( Column(
modifier = modifier modifier = modifier
.width(cardWidth) .width(cardWidth)
@@ -260,7 +330,7 @@ fun TvNextUpCard(
PurefinAsyncImage( PurefinAsyncImage(
model = imageRequest, model = imageRequest,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = imageFocusModifier
.fillMaxSize() .fillMaxSize()
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.clickable { .clickable {
@@ -294,6 +364,9 @@ fun TvLibraryPosterSection(
title: String, title: String,
items: List<PosterItem>, items: List<PosterItem>,
action: String?, action: String?,
firstItemFocusRequester: FocusRequester? = null,
upFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
@@ -308,10 +381,15 @@ fun TvLibraryPosterSection(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items( itemsIndexed(
items = items, key = { it.id }) { item -> items = items,
key = { _, item -> item.id }
) { index, item ->
PosterCard( PosterCard(
item = item, item = item,
focusRequester = if (index == 0) firstItemFocusRequester else null,
upFocusRequester = upFocusRequester,
downFocusRequester = downFocusRequester,
onMovieSelected = onMovieSelected, onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected, onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected onEpisodeSelected = onEpisodeSelected