feat: add initial focus management and media metadata for TV home screen

Introduce a `FocusableItem` interface to unify media item properties (ID, type, text, and images) across `ContinueWatchingItem`, `NextUpItem`, and `PosterItem`.

Key changes:
- Implement automatic initial focus on the first available item in `TvHomeContent`.
- Add `onMediaFocused` callbacks to TV sections and cards to track active items.
- Standardize image URL generation and metadata access in home models.
- Clean up unused previews in `HomeScreen`.
- Add UI tests for TV home content focus behavior.
This commit is contained in:
2026-04-05 17:38:19 +02:00
parent 584e430431
commit 0011420bf2
9 changed files with 355 additions and 76 deletions

View File

@@ -0,0 +1,108 @@
package hu.bbara.purefin.tv.home.ui
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Rule
import org.junit.Test
import java.util.UUID
class TvHomeContentTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun tvHomeContent_focusesFirstAvailableItemByDefault() {
composeRule.setContent {
AppTheme {
TvHomeContent(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
onMediaFocused = {},
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
}
private fun sampleContinueWatching(): List<ContinueWatchingItem> {
return listOf(
ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = Movie(
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
libraryId = UUID.fromString("22222222-2222-2222-2222-222222222222"),
title = "Blade Runner 2049",
progress = 42.0,
watched = false,
year = "2017",
rating = "16+",
runtime = "164m",
format = "4K",
synopsis = "Officer K uncovers a secret that sends him searching for Rick Deckard.",
imageUrlPrefix = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
audioTrack = "ENG 5.1",
subtitles = "ENG",
cast = emptyList()
)
)
)
}
private fun sampleLibraries(): List<LibraryItem> {
return listOf(
LibraryItem(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
name = "Movies",
type = CollectionType.MOVIES,
posterUrl = "",
isEmpty = false
)
)
}
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333")
return mapOf(
libraryId to listOf(
PosterItem(
type = BaseItemKind.MOVIE,
movie = Movie(
id = UUID.fromString("44444444-4444-4444-4444-444444444444"),
libraryId = libraryId,
title = "Arrival",
progress = null,
watched = false,
year = "2016",
rating = "12+",
runtime = "116m",
format = "4K",
synopsis = "A linguist works to communicate with mysterious visitors.",
imageUrlPrefix = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa",
audioTrack = "ENG 5.1",
subtitles = "ENG",
cast = emptyList()
)
)
)
)
}
}

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.common.ui.components.UnwatchedEpisodeIndicator import hu.bbara.purefin.common.ui.components.UnwatchedEpisodeIndicator
import hu.bbara.purefin.common.ui.components.WatchStateIndicator import hu.bbara.purefin.common.ui.components.WatchStateIndicator
import hu.bbara.purefin.feature.shared.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@@ -39,6 +40,8 @@ import org.jellyfin.sdk.model.api.BaseItemKind
fun PosterCard( fun PosterCard(
item: PosterItem, item: PosterItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
onFocusedItem: (FocusableItem) -> Unit = {},
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
@@ -74,7 +77,7 @@ fun PosterCard(
PurefinAsyncImage( PurefinAsyncImage(
model = item.imageUrl, model = item.imageUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = imageModifier
.aspectRatio(2f / 3f) .aspectRatio(2f / 3f)
.clip(RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp))
.border( .border(
@@ -83,7 +86,12 @@ fun PosterCard(
shape = RoundedCornerShape(14.dp) shape = RoundedCornerShape(14.dp)
) )
.background(scheme.surfaceVariant) .background(scheme.surfaceVariant)
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocusedItem(item)
}
}
.clickable(onClick = { openItem(item) }), .clickable(onClick = { openItem(item) }),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )

View File

@@ -35,13 +35,15 @@ fun TvAppScreen(
libraryViewModel: LibraryViewModel = hiltViewModel(), libraryViewModel: LibraryViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var selectedTabIndex by remember { mutableIntStateOf(1) } val serverUrl by viewModel.serverUrl.collectAsState()
val libraries by viewModel.libraries.collectAsState() val libraries by viewModel.libraries.collectAsState()
val continueWatching by viewModel.continueWatching.collectAsState() val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.collectAsState() val nextUp by viewModel.nextUp.collectAsState()
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState() val latestLibraryContent by viewModel.latestLibraryContent.collectAsState()
val selectedLibraryItems by libraryViewModel.contents.collectAsState() val selectedLibraryItems by libraryViewModel.contents.collectAsState()
var selectedTabIndex by remember { mutableIntStateOf(1) }
val tabs = remember(libraries) { val tabs = remember(libraries) {
buildList { buildList {
add( add(
@@ -125,6 +127,7 @@ fun TvAppScreen(
libraryContent = latestLibraryContent, libraryContent = latestLibraryContent,
continueWatching = continueWatching, continueWatching = continueWatching,
nextUp = nextUp, nextUp = nextUp,
serverUrl = serverUrl,
onMovieSelected = viewModel::onMovieSelected, onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected, onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected, onEpisodeSelected = viewModel::onEpisodeSelected,

View File

@@ -2,8 +2,13 @@ package hu.bbara.purefin.tv.home
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.shared.home.PosterItem
@@ -17,19 +22,26 @@ fun TvHomeScreen(
libraryContent: Map<UUID, List<PosterItem>>, libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>, continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>, nextUp: List<NextUpItem>,
serverUrl: String,
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
var focusableItem by remember { mutableStateOf<FocusableItem?>(null) }
TvHomeContent( TvHomeContent(
libraries = libraries, libraries = libraries,
libraryContent = libraryContent, libraryContent = libraryContent,
continueWatching = continueWatching, continueWatching = continueWatching,
nextUp = nextUp, nextUp = nextUp,
onMediaFocused = { item: FocusableItem ->
focusableItem = item
},
onMovieSelected = onMovieSelected, onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected, onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected, onEpisodeSelected = onEpisodeSelected,
modifier = modifier modifier = modifier
) )
} }

View File

@@ -6,46 +6,78 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn 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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
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.Companion.FocusRequesterFactory.component1 import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2 import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
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.graphics.Color
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.FocusableItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
@Composable @Composable
fun TvHomeContent( fun TvHomeContent(
libraries: List<LibraryItem>, libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>, libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>, continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>, nextUp: List<NextUpItem>,
onMediaFocused: (FocusableItem) -> Unit,
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
val visibleLibraries = remember(libraries, libraryContent) { val visibleLibraries = remember(libraries, libraryContent) {
libraries.filter { libraryContent[it.id]?.isEmpty() != true } libraries.filter { libraryContent[it.id]?.isEmpty() != true }
} }
val ( nextUpRef, continueWatchingRef ) = remember { FocusRequester.createRefs() } val ( nextUpRef, continueWatchingRef ) = remember { FocusRequester.createRefs() }
val libraryRefs = remember(libraryContent) { val libraryRefs = remember(visibleLibraries) {
libraryContent.keys.associateWith { FocusRequester() } visibleLibraries.associate { it.id to FocusRequester() }
}
val initialFocusRequester = remember { FocusRequester() }
val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id
val firstAvailableItemKey = remember(
continueWatching,
nextUp,
firstVisibleLibraryId,
libraryContent
) {
when {
continueWatching.isNotEmpty() -> continueWatching.first().id
nextUp.isNotEmpty() -> nextUp.first().id
firstVisibleLibraryId != null -> libraryContent[firstVisibleLibraryId]?.firstOrNull()?.id
else -> null
}
}
var initialFocusApplied by remember { mutableStateOf(false) }
LaunchedEffect(firstAvailableItemKey, initialFocusApplied) {
if (!initialFocusApplied && firstAvailableItemKey != null) {
withFrameNanos { }
initialFocusRequester.requestFocus()
initialFocusApplied = true
}
} }
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(Color.Transparent)
) { ) {
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -53,8 +85,11 @@ fun TvHomeContent(
item { item {
TvContinueWatchingSection( TvContinueWatchingSection(
items = continueWatching, items = continueWatching,
onFocusedItem = onMediaFocused,
onMovieSelected = onMovieSelected, onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected, onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester.takeIf { continueWatching.isNotEmpty() },
firstItemTestTag = TvHomeInitialFocusTag.takeIf { continueWatching.isNotEmpty() },
modifier = Modifier.focusRequester(continueWatchingRef) modifier = Modifier.focusRequester(continueWatchingRef)
.focusProperties { .focusProperties {
down = nextUpRef down = nextUpRef
@@ -67,7 +102,12 @@ fun TvHomeContent(
item { item {
TvNextUpSection( TvNextUpSection(
items = nextUp, items = nextUp,
onFocusedItem = onMediaFocused,
onEpisodeSelected = onEpisodeSelected, onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
firstItemTestTag = TvHomeInitialFocusTag
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
modifier = Modifier.focusRequester(nextUpRef) modifier = Modifier.focusRequester(nextUpRef)
.focusProperties { .focusProperties {
up = continueWatchingRef up = continueWatchingRef
@@ -87,10 +127,21 @@ fun TvHomeContent(
title = item.name, title = item.name,
items = libraryContent[item.id] ?: emptyList(), items = libraryContent[item.id] ?: emptyList(),
action = "See All", action = "See All",
onFocusedItem = onMediaFocused,
onMovieSelected = onMovieSelected, onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected, onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected, onEpisodeSelected = onEpisodeSelected,
modifier = if (ref != null) Modifier.focusRequester(ref) else Modifier firstItemFocusRequester = initialFocusRequester.takeIf {
continueWatching.isEmpty() &&
nextUp.isEmpty() &&
item.id == firstVisibleLibraryId
},
firstItemTestTag = TvHomeInitialFocusTag.takeIf {
continueWatching.isEmpty() &&
nextUp.isEmpty() &&
item.id == firstVisibleLibraryId
},
modifier = (if (ref != null) Modifier.focusRequester(ref) else Modifier)
.focusProperties { .focusProperties {
up = nextUpRef up = nextUpRef
libraryRefs.values.dropWhile { it != ref }.drop(1).firstOrNull() libraryRefs.values.dropWhile { it != ref }.drop(1).firstOrNull()
@@ -100,4 +151,4 @@ fun TvHomeContent(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
} }
} }

View File

@@ -28,10 +28,13 @@ 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.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -41,6 +44,7 @@ import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
@@ -51,8 +55,11 @@ import kotlin.math.nextUp
@Composable @Composable
fun TvContinueWatchingSection( fun TvContinueWatchingSection(
items: List<ContinueWatchingItem>, items: List<ContinueWatchingItem>,
onFocusedItem: (FocusableItem) -> Unit = {},
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
@@ -66,9 +73,25 @@ fun TvContinueWatchingSection(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
itemsIndexed(items = items) { index, item -> itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
TvContinueWatchingCard( TvContinueWatchingCard(
item = item, item = item,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
),
onFocusedItem = onFocusedItem,
onMovieSelected = onMovieSelected, onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected onEpisodeSelected = onEpisodeSelected
) )
@@ -80,6 +103,8 @@ fun TvContinueWatchingSection(
fun TvContinueWatchingCard( fun TvContinueWatchingCard(
item: ContinueWatchingItem, item: ContinueWatchingItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
onFocusedItem: (FocusableItem) -> Unit = {},
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) { ) {
@@ -139,9 +164,14 @@ fun TvContinueWatchingCard(
PurefinAsyncImage( PurefinAsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = imageModifier
.fillMaxSize() .fillMaxSize()
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocusedItem(item)
}
}
.clickable { .clickable {
openItem(item) openItem(item)
}, },
@@ -178,7 +208,10 @@ fun TvContinueWatchingCard(
@Composable @Composable
fun TvNextUpSection( fun TvNextUpSection(
items: List<NextUpItem>, items: List<NextUpItem>,
onFocusedItem: (FocusableItem) -> Unit = {},
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
@@ -192,14 +225,25 @@ fun TvNextUpSection(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
itemsIndexed( itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
items = items,
key = { _, item -> item.id }
) { index, item ->
TvNextUpCard( TvNextUpCard(
item = item, item = item,
isFirstItem = index == 0, imageModifier = Modifier
isLastItem = index == items.lastIndex, .then(
if (index == 0 && firstItemFocusRequester != null) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
),
onFocusedItem = onFocusedItem,
onEpisodeSelected = onEpisodeSelected onEpisodeSelected = onEpisodeSelected
) )
} }
@@ -210,8 +254,8 @@ fun TvNextUpSection(
fun TvNextUpCard( fun TvNextUpCard(
item: NextUpItem, item: NextUpItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isFirstItem: Boolean = false, imageModifier: Modifier = Modifier,
isLastItem: Boolean = false, onFocusedItem: (FocusableItem) -> Unit = {},
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -219,7 +263,7 @@ fun TvNextUpCard(
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale") val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
val imageUrl = JellyfinImageHelper.finishImageUrl(item.episode.imageUrlPrefix, ImageType.PRIMARY) val imageUrl = item.imageUrl
val cardWidth = 280.dp val cardWidth = 280.dp
@@ -253,9 +297,14 @@ fun TvNextUpCard(
PurefinAsyncImage( PurefinAsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = imageModifier
.fillMaxSize() .fillMaxSize()
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocusedItem(item)
}
}
.clickable { .clickable {
openItem(item) openItem(item)
}, },
@@ -287,6 +336,9 @@ fun TvLibraryPosterSection(
title: String, title: String,
items: List<PosterItem>, items: List<PosterItem>,
action: String?, action: String?,
onFocusedItem: (FocusableItem) -> Unit = {},
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
@@ -302,12 +354,25 @@ fun TvLibraryPosterSection(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
itemsIndexed( itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
items = items,
key = { _, item -> item.id }
) { index, item ->
PosterCard( PosterCard(
item = item, item = item,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
),
onFocusedItem = onFocusedItem,
onMovieSelected = onMovieSelected, onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected, onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected onEpisodeSelected = onEpisodeSelected

View File

@@ -13,20 +13,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import hu.bbara.purefin.app.home.ui.HomeContent import hu.bbara.purefin.app.home.ui.HomeContent
import hu.bbara.purefin.app.home.ui.HomeTopBar import hu.bbara.purefin.app.home.ui.HomeTopBar
import hu.bbara.purefin.app.home.ui.homePreviewContinueWatching
import hu.bbara.purefin.app.home.ui.homePreviewLibraries
import hu.bbara.purefin.app.home.ui.homePreviewLibraryContent
import hu.bbara.purefin.app.home.ui.homePreviewNextUp
import hu.bbara.purefin.app.home.ui.search.HomeSearchOverlay import hu.bbara.purefin.app.home.ui.search.HomeSearchOverlay
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
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.shared.home.SuggestedItem import hu.bbara.purefin.feature.shared.home.SuggestedItem
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -114,29 +108,4 @@ fun HomeScreen(
) )
} }
} }
} }
@Preview(name = "Home Screen", showBackground = true, widthDp = 412, heightDp = 915)
@Composable
private fun HomeScreenPreview() {
AppTheme(darkTheme = true) {
HomeScreen(
libraries = homePreviewLibraries(),
libraryContent = homePreviewLibraryContent(),
suggestions = emptyList(),
continueWatching = homePreviewContinueWatching(),
nextUp = homePreviewNextUp(),
isRefreshing = false,
onRefresh = {},
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> },
onLibrarySelected = {},
onProfileClick = {},
onSettingsClick = {},
onLogoutClick = {},
selectedTab = 0,
onTabSelected = {}
)
}
}

View File

@@ -39,6 +39,13 @@ class AppViewModel @Inject constructor(
private val _isRefreshing = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow() val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
val serverUrl: StateFlow<String> = userSessionRepository.serverUrl
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
""
)
val libraries = appContentRepository.libraries.map { libraries -> val libraries = appContentRepository.libraries.map { libraries ->
libraries.map { libraries.map {
LibraryItem( LibraryItem(

View File

@@ -88,26 +88,51 @@ data class SuggestedMovie (
) )
) : SuggestedItem ) : SuggestedItem
sealed interface FocusableItem {
val imageUrl: String
val primaryText: String
val secondaryText: String
val description: String
val id: UUID
val type: BaseItemKind
}
data class ContinueWatchingItem( data class ContinueWatchingItem(
val type: BaseItemKind, override val type: BaseItemKind,
val movie: Movie? = null, val movie: Movie? = null,
val episode: Episode? = null val episode: Episode? = null,
) { ) : FocusableItem {
val id: UUID = when (type) { override val id: UUID = when (type) {
BaseItemKind.MOVIE -> movie!!.id BaseItemKind.MOVIE -> movie!!.id
BaseItemKind.EPISODE -> episode!!.id BaseItemKind.EPISODE -> episode!!.id
else -> throw UnsupportedOperationException("Unsupported item type: $type") else -> throw UnsupportedOperationException("Unsupported item type: $type")
} }
val primaryText: String = when (type) { override val primaryText: String = when (type) {
BaseItemKind.MOVIE -> movie!!.title BaseItemKind.MOVIE -> movie!!.title
BaseItemKind.EPISODE -> episode!!.title BaseItemKind.EPISODE -> episode!!.title
else -> throw UnsupportedOperationException("Unsupported item type: $type") else -> throw UnsupportedOperationException("Unsupported item type: $type")
} }
val secondaryText: String = when (type) { override val secondaryText: String = when (type) {
BaseItemKind.MOVIE -> movie!!.year BaseItemKind.MOVIE -> movie!!.year
BaseItemKind.EPISODE -> episode!!.releaseDate BaseItemKind.EPISODE -> episode!!.releaseDate
else -> throw UnsupportedOperationException("Unsupported item type: $type") else -> throw UnsupportedOperationException("Unsupported item type: $type")
} }
override val imageUrl: String = when (type) {
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = movie!!.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = episode!!.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
else -> throw IllegalArgumentException("Invalid type: $type")
}
override val description: String = when (type) {
BaseItemKind.MOVIE -> movie!!.synopsis
BaseItemKind.EPISODE -> episode!!.synopsis
else -> throw UnsupportedOperationException("Unsupported item type: $type")
}
val progress: Double = when (type) { val progress: Double = when (type) {
BaseItemKind.MOVIE -> movie!!.progress ?: 0.0 BaseItemKind.MOVIE -> movie!!.progress ?: 0.0
BaseItemKind.EPISODE -> episode!!.progress ?: 0.0 BaseItemKind.EPISODE -> episode!!.progress ?: 0.0
@@ -117,10 +142,19 @@ data class ContinueWatchingItem(
data class NextUpItem( data class NextUpItem(
val episode: Episode val episode: Episode
) { ) : FocusableItem {
val id: UUID = episode.id override val id: UUID = episode.id
val primaryText: String = episode.title override val type: BaseItemKind
val secondaryText: String = episode.releaseDate get() = BaseItemKind.EPISODE
override val imageUrl: String
get() = JellyfinImageHelper.finishImageUrl(
prefixImageUrl = episode.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
override val primaryText: String = episode.title
override val secondaryText: String = episode.releaseDate
override val description: String
get() = episode.synopsis
} }
data class LibraryItem( data class LibraryItem(
@@ -132,12 +166,12 @@ data class LibraryItem(
) )
data class PosterItem( data class PosterItem(
val type: BaseItemKind, override val type: BaseItemKind,
val movie: Movie? = null, val movie: Movie? = null,
val series: Series? = null, val series: Series? = null,
val episode: Episode? = null val episode: Episode? = null
) { ) : FocusableItem {
val id: UUID = when (type) { override val id: UUID = when (type) {
BaseItemKind.MOVIE -> movie!!.id BaseItemKind.MOVIE -> movie!!.id
BaseItemKind.EPISODE -> episode!!.id BaseItemKind.EPISODE -> episode!!.id
BaseItemKind.SERIES -> series!!.id BaseItemKind.SERIES -> series!!.id
@@ -149,7 +183,7 @@ data class PosterItem(
BaseItemKind.SERIES -> series!!.name BaseItemKind.SERIES -> series!!.name
else -> throw IllegalArgumentException("Invalid type: $type") else -> throw IllegalArgumentException("Invalid type: $type")
} }
val imageUrl: String = when (type) { override val imageUrl: String = when (type) {
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl( BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = movie!!.imageUrlPrefix, prefixImageUrl = movie!!.imageUrlPrefix,
imageType = ImageType.PRIMARY imageType = ImageType.PRIMARY
@@ -164,6 +198,28 @@ data class PosterItem(
) )
else -> throw IllegalArgumentException("Invalid type: $type") else -> throw IllegalArgumentException("Invalid type: $type")
} }
override val primaryText: String
get() = when (type) {
BaseItemKind.MOVIE -> movie!!.title
BaseItemKind.EPISODE -> episode!!.title
BaseItemKind.SERIES -> series!!.name
else -> throw IllegalArgumentException("Invalid type: $type")
}
override val secondaryText: String
get() = when (type) {
BaseItemKind.MOVIE -> movie!!.year
BaseItemKind.EPISODE -> episode!!.releaseDate
BaseItemKind.SERIES -> series!!.year
else -> throw IllegalArgumentException("Invalid type: $type")
}
override val description: String
get() = when (type) {
BaseItemKind.MOVIE -> movie!!.synopsis
BaseItemKind.EPISODE -> episode!!.synopsis
BaseItemKind.SERIES -> series!!.synopsis
else -> throw IllegalArgumentException("Invalid type: $type")
}
fun watched() = when (type) { fun watched() = when (type) {
BaseItemKind.MOVIE -> movie!!.watched BaseItemKind.MOVIE -> movie!!.watched
BaseItemKind.EPISODE -> episode!!.watched BaseItemKind.EPISODE -> episode!!.watched