Compare commits

...

2 Commits

Author SHA1 Message Date
69b3933175 feat: added TvHomeFocused section to the TvHomeScreen 2026-04-05 19:54:13 +02:00
0011420bf2 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.
2026-04-05 17:38:19 +02:00
20 changed files with 1756 additions and 367 deletions

View File

@@ -0,0 +1,230 @@
package hu.bbara.purefin.tv.home.ui
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.unit.dp
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.tv.home.TvHomeScreen
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
@OptIn(ExperimentalTestApi::class)
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()
}
@Test
fun tvHomeScreen_showsHeroForInitiallyFocusedItem() {
composeRule.setContent {
AppTheme {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
composeRule.onNodeWithTag(TvHomeHeroProgressLabelTag).assertTextEquals("42%")
}
@Test
fun tvHomeScreen_updatesHeroWhenFocusMovesWithinRow() {
composeRule.setContent {
AppTheme {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatchingRow(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
composeRule.onNodeWithTag(TvHomeInitialFocusTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Mad Max: Fury Road")
}
@Test
fun tvHomeScreen_compactHeroKeepsFirstRowVisible() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertIsDisplayed()
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
composeRule.onNodeWithTag(TvHomeInitialFocusTag)
.assertIsDisplayed()
.assertIsFocused()
}
private fun sampleContinueWatching(): List<ContinueWatchingItem> {
return listOf(
ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(
id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049",
progress = 42.0
)
)
)
}
private fun sampleContinueWatchingRow(): List<ContinueWatchingItem> {
return listOf(
ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(
id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049",
progress = 42.0
)
),
ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(
id = "55555555-5555-5555-5555-555555555555",
title = "Mad Max: Fury Road",
progress = 8.0
)
)
)
}
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 = sampleMovie(
id = "44444444-4444-4444-4444-444444444444",
title = "Arrival",
progress = null,
libraryId = libraryId
)
)
)
)
}
private fun sampleMovie(
id: String,
title: String,
progress: Double?,
libraryId: UUID = UUID.fromString("22222222-2222-2222-2222-222222222222")
): Movie {
return Movie(
id = UUID.fromString(id),
libraryId = libraryId,
title = title,
progress = progress,
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()
)
}
}

View File

@@ -0,0 +1,61 @@
package hu.bbara.purefin.tv.home.ui
import androidx.activity.ComponentActivity
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Search
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Rule
import org.junit.Test
class TvHomeTopBarTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun tvHomeTopBar_updatesSelectedTab() {
var selectedTabIndex by mutableIntStateOf(1)
composeRule.setContent {
AppTheme {
TvHomeTopBar(
tabs = listOf(
TvHomeTabItem(
destination = TvHomeTabDestination.SEARCH,
label = "Search",
icon = Icons.Outlined.Search
),
TvHomeTabItem(
destination = TvHomeTabDestination.HOME,
label = "Home",
icon = Icons.Outlined.Home
)
),
selectedTabIndex = selectedTabIndex,
onTabSelected = { index, _ ->
selectedTabIndex = index
}
)
}
}
composeRule.onNodeWithTag("${TvHomeTabTagPrefix}1").assertIsSelected()
composeRule.onNodeWithTag("${TvHomeTabTagPrefix}0")
.assertIsNotSelected()
.performClick()
composeRule.waitForIdle()
composeRule.onNodeWithTag("${TvHomeTabTagPrefix}0").assertIsSelected()
composeRule.onNodeWithTag("${TvHomeTabTagPrefix}1").assertIsNotSelected()
}
}

View File

@@ -26,11 +26,13 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.common.ui.components.UnwatchedEpisodeIndicator
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 org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -39,6 +41,12 @@ import org.jellyfin.sdk.model.api.BaseItemKind
fun PosterCard(
item: PosterItem,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
onFocusedItem: (FocusableItem) -> Unit = {},
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
@@ -47,8 +55,6 @@ fun PosterCard(
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
val posterWidth = 144.dp
fun openItem(posterItem: PosterItem) {
when (posterItem.type) {
BaseItemKind.MOVIE -> onMovieSelected(posterItem.id)
@@ -70,11 +76,11 @@ fun PosterCard(
transformOrigin = TransformOrigin(0.5f, 0f)
}
) {
Box() {
Box {
PurefinAsyncImage(
model = item.imageUrl,
contentDescription = null,
modifier = Modifier
modifier = imageModifier
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(14.dp))
.border(
@@ -83,7 +89,12 @@ fun PosterCard(
shape = RoundedCornerShape(14.dp)
)
.background(scheme.surfaceVariant)
.onFocusChanged { isFocused = it.isFocused }
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocusedItem(item)
}
}
.clickable(onClick = { openItem(item) }),
contentScale = ContentScale.Crop
)
@@ -91,9 +102,9 @@ fun PosterCard(
BaseItemKind.MOVIE -> {
val m = item.movie!!
WatchStateIndicator(
size = 28,
size = indicatorSize,
modifier = Modifier.align(Alignment.TopEnd)
.padding(8.dp),
.padding(indicatorPadding),
watched = m.watched,
started = (m.progress ?: 0.0) > 0
)
@@ -101,30 +112,45 @@ fun PosterCard(
BaseItemKind.EPISODE -> {
val ep = item.episode!!
WatchStateIndicator(
size = 28,
size = indicatorSize,
modifier = Modifier.align(Alignment.TopEnd)
.padding(8.dp),
.padding(indicatorPadding),
watched = ep.watched,
started = (ep.progress ?: 0.0) > 0
)
}
BaseItemKind.SERIES -> UnwatchedEpisodeIndicator(
size = 28,
size = indicatorSize,
modifier = Modifier.align(Alignment.TopEnd)
.padding(8.dp),
.padding(indicatorPadding),
unwatchedCount = item.series!!.unwatchedEpisodeCount
)
else -> {}
}
}
Text(
text = item.title,
color = scheme.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Column(
modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp)
) {
Text(
text = item.title,
color = scheme.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
item.secondaryText
.takeIf { showSecondaryText }
?.takeIf { it.isNotBlank() }
?.let { text ->
Text(
text = text,
color = scheme.onSurfaceVariant,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -18,7 +18,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -61,7 +60,7 @@ internal fun TvMediaDetailScaffold(
heightFraction = heroHeightFraction,
modifier = Modifier.fillMaxWidth()
)
MediaDetailHeroGradientOverlay()
MediaHeroScrimOverlay()
Column(
modifier = contentPadding
.padding(top = 104.dp, bottom = 36.dp)
@@ -131,36 +130,3 @@ internal fun MediaDetailPlaybackSection(
)
}
}
@Composable
private fun MediaDetailHeroGradientOverlay() {
val background = MaterialTheme.colorScheme.background
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
colors = listOf(
background,
background.copy(alpha = 0.95f),
background.copy(alpha = 0.7f),
background.copy(alpha = 0.15f)
)
)
)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
background.copy(alpha = 0.05f),
background.copy(alpha = 0.2f),
background
)
)
)
)
}

View File

@@ -0,0 +1,42 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@Composable
internal fun MediaHeroScrimOverlay(modifier: Modifier = Modifier) {
val background = MaterialTheme.colorScheme.background
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
colors = listOf(
background,
background.copy(alpha = 0.95f),
background.copy(alpha = 0.7f),
background.copy(alpha = 0.15f)
)
)
)
)
Box(
modifier = modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
background.copy(alpha = 0.05f),
background.copy(alpha = 0.2f),
background
)
)
)
)
}

View File

@@ -2,6 +2,7 @@ package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -12,6 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
@@ -27,15 +29,18 @@ fun MediaProgressBar(
progress: Float,
foregroundColor: Color = MaterialTheme.colorScheme.onSurface,
backgroundColor: Color = MaterialTheme.colorScheme.primary,
contentPadding: PaddingValues = PaddingValues(start = 8.dp, end = 8.dp, bottom = 8.dp),
barHeight: Dp = 4.dp,
cornerRadius: Dp = 24.dp,
modifier: Modifier
) {
if (progress == 0f) return
if (progress <= 0f) return
Box(
modifier = modifier
.padding(bottom = 8.dp, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(24.dp))
.padding(contentPadding)
.clip(RoundedCornerShape(cornerRadius))
.fillMaxWidth()
.height(4.dp)
.height(barHeight)
.background(backgroundColor.copy(alpha = 0.2f))
) {
Box(
@@ -45,4 +50,4 @@ fun MediaProgressBar(
.background(foregroundColor)
)
}
}
}

View File

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

View File

@@ -1,35 +1,79 @@
package hu.bbara.purefin.tv.home
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.tv.home.ui.TvFocusedItemHero
import hu.bbara.purefin.tv.home.ui.TvHomeContent
import hu.bbara.purefin.tv.home.ui.rememberTvHomeHeroState
import org.jellyfin.sdk.model.UUID
@SuppressLint("RememberInComposition")
private const val TvHomeHeroHeightFraction = 0.32f
private val TvHomeMinHeroHeight = 160.dp
private val TvHomeMaxHeroHeight = 200.dp
@Composable
fun TvHomeScreen(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
serverUrl: String,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
TvHomeContent(
val scheme = MaterialTheme.colorScheme
val heroState = rememberTvHomeHeroState(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected,
modifier = modifier
nextUp = nextUp
)
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
) {
val heroHeight = heroState.focusedHero?.let {
(maxHeight * TvHomeHeroHeightFraction)
.coerceIn(TvHomeMinHeroHeight, TvHomeMaxHeroHeight)
}
Column(
modifier = Modifier.fillMaxSize()
) {
heroState.focusedHero?.let { hero ->
TvFocusedItemHero(
item = hero,
height = heroHeight ?: TvHomeMinHeroHeight
)
}
TvHomeContent(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMediaFocused = heroState.onMediaFocused,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
)
}
}
}

View File

@@ -0,0 +1,140 @@
package hu.bbara.purefin.tv.home.ui
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
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.PosterItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import kotlin.math.roundToInt
internal data class TvFocusedHeroModel(
val id: UUID,
val backdropImageUrl: String,
val eyebrowText: String,
val title: String,
val metadata: List<String>,
val watchedText: String?,
val progressFraction: Float?,
val progressLabel: String?,
) {
val metadataText: String?
get() = metadata.takeIf { it.isNotEmpty() }?.joinToString("")
}
internal fun FocusableItem.toTvFocusedHeroModel(): TvFocusedHeroModel {
return when (this) {
is ContinueWatchingItem -> when (type) {
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel(sourceLabel = "Continue watching")
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel(sourceLabel = "Continue watching")
else -> unsupportedType(type)
}
is NextUpItem -> episode.toTvFocusedHeroModel(sourceLabel = "Next up")
is PosterItem -> when (type) {
BaseItemKind.MOVIE -> movie!!.toTvFocusedHeroModel(sourceLabel = "Movie")
BaseItemKind.EPISODE -> episode!!.toTvFocusedHeroModel(sourceLabel = "Episode")
BaseItemKind.SERIES -> series!!.toTvFocusedHeroModel(sourceLabel = "Series")
else -> unsupportedType(type)
}
}
}
internal fun backdropImageUrl(imageUrlPrefix: String?, fallbackImageUrl: String): String {
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP)
return backdropImageUrl.ifBlank { fallbackImageUrl }
}
private fun Movie.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel {
val progressFraction = progressFraction(progress)
return TvFocusedHeroModel(
id = id,
backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
),
eyebrowText = sourceLabel,
title = title,
metadata = listOf(year, rating, runtime, format).compactMetadata(),
watchedText = "Watched".takeIf { watched },
progressFraction = progressFraction.takeIf { !watched && it != null },
progressLabel = progressLabel(progressFraction).takeIf { !watched && progressFraction != null },
)
}
private fun Episode.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel {
val progressFraction = progressFraction(progress)
return TvFocusedHeroModel(
id = id,
backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
),
eyebrowText = sourceLabel,
title = title,
metadata = listOf(
"Episode $index",
releaseDate,
runtime,
rating,
format
).compactMetadata(),
watchedText = "Watched".takeIf { watched },
progressFraction = progressFraction.takeIf { !watched && it != null },
progressLabel = progressLabel(progressFraction).takeIf { !watched && progressFraction != null },
)
}
private fun Series.toTvFocusedHeroModel(sourceLabel: String): TvFocusedHeroModel {
val unwatchedText = if (unwatchedEpisodeCount > 0) {
"$unwatchedEpisodeCount unwatched"
} else {
null
}
return TvFocusedHeroModel(
id = id,
backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
),
eyebrowText = sourceLabel,
title = name,
metadata = listOf(year, seasonLabel(seasonCount), unwatchedText).compactMetadata(),
watchedText = null,
progressFraction = null,
progressLabel = null,
)
}
private fun progressFraction(progress: Double?): Float? {
val fraction = progress
?.div(100.0)
?.toFloat()
?.coerceIn(0f, 1f)
return fraction?.takeIf { it > 0f }
}
private fun progressLabel(progressFraction: Float?): String? {
return progressFraction?.let { "${(it * 100).roundToInt()}%" }
}
private fun List<String?>.compactMetadata(): List<String> {
return map(String?::orEmpty)
.map(String::trim)
.filter(String::isNotBlank)
}
private fun seasonLabel(seasonCount: Int): String {
return if (seasonCount == 1) "1 season" else "$seasonCount seasons"
}
private fun unsupportedType(type: BaseItemKind): Nothing {
throw UnsupportedOperationException("Unsupported item type: $type")
}

View File

@@ -0,0 +1,193 @@
package hu.bbara.purefin.tv.home.ui
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.components.MediaHeroScrimOverlay
import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
internal const val TvHomeHeroStatusTag = "tv-home-hero-status"
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
private const val TvHomeHeroAnimationMillis = 180
private val TvHomeHeroShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
@Composable
internal fun TvFocusedItemHero(
item: TvFocusedHeroModel,
height: Dp,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.clip(TvHomeHeroShape)
.background(scheme.background)
) {
Crossfade(
targetState = item.backdropImageUrl,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-background"
) { imageUrl ->
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
MediaHeroScrimOverlay()
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
colors = listOf(
scheme.background,
scheme.background.copy(alpha = 0.94f),
scheme.background.copy(alpha = 0.72f),
scheme.background.copy(alpha = 0.18f)
)
)
)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
scheme.background.copy(alpha = 0f),
scheme.background.copy(alpha = 0.22f),
scheme.background.copy(alpha = 0.92f)
)
)
)
)
Crossfade(
targetState = item,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-content"
) { hero ->
Column(
verticalArrangement = Arrangement.Bottom,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 18.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.widthIn(max = 720.dp)
) {
Text(
text = hero.eyebrowText.uppercase(),
color = scheme.primary,
fontSize = 10.sp,
letterSpacing = 1.2.sp,
fontWeight = FontWeight.Bold
)
Text(
text = hero.title,
color = scheme.onBackground,
fontSize = 34.sp,
fontWeight = FontWeight.Bold,
lineHeight = 38.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(TvHomeHeroTitleTag)
)
hero.metadataText?.let { metadataText ->
Text(
text = metadataText,
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (hero.watchedText != null || hero.progressFraction != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.clip(RoundedCornerShape(22.dp))
.background(scheme.surfaceContainerHigh.copy(alpha = 0.92f))
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.6f), RoundedCornerShape(22.dp))
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
hero.watchedText?.let { watchedText ->
TvHomeMetaChip(
text = watchedText,
highlighted = true,
modifier = Modifier.testTag(TvHomeHeroStatusTag)
)
}
if (hero.progressFraction != null && hero.progressLabel != null) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.width(188.dp)
) {
Text(
text = "Progress",
color = scheme.onSurfaceVariant.copy(alpha = 0.85f),
fontSize = 10.sp,
fontWeight = FontWeight.Medium
)
MediaProgressBar(
progress = hero.progressFraction,
foregroundColor = scheme.primary,
backgroundColor = scheme.surfaceVariant,
contentPadding = PaddingValues(0.dp),
barHeight = 6.dp,
modifier = Modifier
)
}
Text(
text = hero.progressLabel,
color = scheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.End,
modifier = Modifier.testTag(TvHomeHeroProgressLabelTag)
)
}
}
}
}
}
}
}
}

View File

@@ -1,6 +1,7 @@
package hu.bbara.purefin.tv.home.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
@@ -8,7 +9,12 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
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.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
@@ -17,35 +23,57 @@ import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
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.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
@Composable
fun TvHomeContent(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
onMediaFocused: (FocusableItem) -> Unit,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
modifier: Modifier = Modifier,
) {
val visibleLibraries = remember(libraries, libraryContent) {
libraries.filter { libraryContent[it.id]?.isEmpty() != true }
val scheme = MaterialTheme.colorScheme
val itemRegistry = rememberTvHomeItemRegistry(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp
)
val visibleLibraries = itemRegistry.visibleLibraries
val (nextUpRef, continueWatchingRef) = remember { FocusRequester.createRefs() }
val libraryRefs = remember(visibleLibraries) {
visibleLibraries.associate { it.id to FocusRequester() }
}
val initialFocusRequester = remember { FocusRequester() }
val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id
val firstAvailableItemKey = itemRegistry.firstAvailableItemId
var initialFocusApplied by remember { mutableStateOf(false) }
val ( nextUpRef, continueWatchingRef ) = remember { FocusRequester.createRefs() }
val libraryRefs = remember(libraryContent) {
libraryContent.keys.associateWith { FocusRequester() }
LaunchedEffect(firstAvailableItemKey, initialFocusApplied) {
if (!initialFocusApplied && firstAvailableItemKey != null) {
withFrameNanos { }
initialFocusRequester.requestFocus()
initialFocusApplied = true
}
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.background(scheme.background),
contentPadding = contentPadding
) {
item {
Spacer(modifier = Modifier.height(8.dp))
@@ -53,8 +81,11 @@ fun TvHomeContent(
item {
TvContinueWatchingSection(
items = continueWatching,
onFocusedItem = onMediaFocused,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester.takeIf { continueWatching.isNotEmpty() },
firstItemTestTag = TvHomeInitialFocusTag.takeIf { continueWatching.isNotEmpty() },
modifier = Modifier.focusRequester(continueWatchingRef)
.focusProperties {
down = nextUpRef
@@ -62,12 +93,17 @@ fun TvHomeContent(
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(20.dp))
}
item {
TvNextUpSection(
items = nextUp,
onFocusedItem = onMediaFocused,
onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
firstItemTestTag = TvHomeInitialFocusTag
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
modifier = Modifier.focusRequester(nextUpRef)
.focusProperties {
up = continueWatchingRef
@@ -76,7 +112,7 @@ fun TvHomeContent(
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(20.dp))
}
items(
items = visibleLibraries,
@@ -87,17 +123,28 @@ fun TvHomeContent(
title = item.name,
items = libraryContent[item.id] ?: emptyList(),
action = "See All",
onFocusedItem = onMediaFocused,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
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 {
up = nextUpRef
libraryRefs.values.dropWhile { it != ref }.drop(1).firstOrNull()
?.let { down = it }
}
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(20.dp))
}
}
}
}

View File

@@ -0,0 +1,111 @@
package hu.bbara.purefin.tv.home.ui
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 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.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
internal data class TvHomeItemRegistry(
val visibleLibraries: List<LibraryItem>,
private val libraryContent: Map<UUID, List<PosterItem>>,
private val continueWatching: List<ContinueWatchingItem>,
private val nextUp: List<NextUpItem>,
val firstAvailableItemId: UUID?,
) {
val firstAvailableItem: FocusableItem?
get() = firstAvailableItemId?.let(::itemById)
fun itemById(id: UUID): FocusableItem? {
return continueWatching.firstOrNull { it.id == id }
?: nextUp.firstOrNull { it.id == id }
?: visibleLibraries.asSequence()
.mapNotNull { library -> libraryContent[library.id] }
.flatten()
.firstOrNull { it.id == id }
}
}
internal class TvHomeHeroState(
val focusedHero: TvFocusedHeroModel?,
val onMediaFocused: (FocusableItem) -> Unit,
)
@Composable
internal fun rememberTvHomeItemRegistry(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
): TvHomeItemRegistry {
return remember(libraries, libraryContent, continueWatching, nextUp) {
createTvHomeItemRegistry(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp
)
}
}
internal fun createTvHomeItemRegistry(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
): TvHomeItemRegistry {
val visibleLibraries = libraries.filter { libraryContent[it.id]?.isEmpty() != true }
val firstAvailableItemId = continueWatching.firstOrNull()?.id
?: nextUp.firstOrNull()?.id
?: visibleLibraries.firstOrNull()?.id?.let { libraryId ->
libraryContent[libraryId]?.firstOrNull()?.id
}
return TvHomeItemRegistry(
visibleLibraries = visibleLibraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
firstAvailableItemId = firstAvailableItemId
)
}
@Composable
internal fun rememberTvHomeHeroState(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
): TvHomeHeroState {
val itemRegistry = rememberTvHomeItemRegistry(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp
)
var focusedItemId by remember { mutableStateOf<UUID?>(null) }
val focusedItem = remember(focusedItemId, itemRegistry) {
focusedItemId?.let(itemRegistry::itemById)
}
val focusedHero = remember(focusedItem, itemRegistry) {
(focusedItem ?: itemRegistry.firstAvailableItem)?.toTvFocusedHeroModel()
}
val onMediaFocused: (FocusableItem) -> Unit = remember {
{ item ->
focusedItemId = item.id
}
}
return remember(focusedHero, onMediaFocused) {
TvHomeHeroState(
focusedHero = focusedHero,
onMediaFocused = onMediaFocused
)
}
}

View File

@@ -0,0 +1,46 @@
package hu.bbara.purefin.tv.home.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val TvHomeMetaChipShape = RoundedCornerShape(999.dp)
@Composable
internal fun TvHomeMetaChip(
text: String,
modifier: Modifier = Modifier,
highlighted: Boolean = false,
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.clip(TvHomeMetaChipShape)
.background(
if (highlighted) {
scheme.primary.copy(alpha = 0.16f)
} else {
scheme.surfaceContainerHigh.copy(alpha = 0.82f)
}
)
.padding(horizontal = 12.dp, vertical = 7.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = if (highlighted) scheme.primary else scheme.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
}

View File

@@ -28,10 +28,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.graphics.Brush
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -41,18 +45,28 @@ import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
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.PosterItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import kotlin.math.nextUp
private val TvHomeSectionsThumbShape = RoundedCornerShape(20.dp)
private val TvHomeSectionsPillShape = RoundedCornerShape(18.dp)
private val TvHomeSectionsHorizontalPadding = 32.dp
private val TvHomeSectionsRowSpacing = 18.dp
private val TvHomeLandscapeCardWidth = 248.dp
private val TvHomePosterCardWidth = 136.dp
@Composable
fun TvContinueWatchingSection(
items: List<ContinueWatchingItem>,
onFocusedItem: (FocusableItem) -> Unit = {},
onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
@@ -61,115 +75,57 @@ fun TvContinueWatchingSection(
action = null
)
LazyRow(
modifier = modifier
.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) {
itemsIndexed(items = items) { index, item ->
TvContinueWatchingCard(
item = item,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected
)
}
}
}
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f)
TvHomeLandscapeCard(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = when (item.type) {
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = item.movie?.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
@Composable
fun TvContinueWatchingCard(
item: ContinueWatchingItem,
modifier: Modifier = Modifier,
onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) {
val scheme = MaterialTheme.colorScheme
BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = item.episode?.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
else -> null
},
badgeText = if (progressFraction > 0f) "Resume" else null,
progress = progressFraction,
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(item) },
onClick = {
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
BaseItemKind.EPISODE -> {
val episode = item.episode!!
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
val imageUrl = when (item.type) {
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = item.movie?.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = item.episode?.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
else -> null
}
val cardWidth = 280.dp
fun openItem(item: ContinueWatchingItem) {
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
BaseItemKind.EPISODE -> {
val episode = item.episode!!
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
else -> {}
}
}
Column(
modifier = modifier
.width(cardWidth)
.wrapContentHeight()
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0.5f, 0f)
}
) {
Box(
modifier = Modifier
.width(cardWidth)
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(16.dp))
.border(
width = if (isFocused) 2.dp else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f),
shape = RoundedCornerShape(16.dp)
)
.background(scheme.surfaceVariant)
) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.onFocusChanged { isFocused = it.isFocused }
.clickable {
openItem(item)
},
contentScale = ContentScale.Crop,
)
MediaProgressBar(
progress = item.progress.toFloat().nextUp().div(100),
foregroundColor = scheme.onSurface,
backgroundColor = scheme.primary,
modifier = Modifier
.align(Alignment.BottomStart)
)
}
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
text = item.primaryText,
color = scheme.onBackground,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = item.secondaryText,
color = scheme.onSurfaceVariant,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
else -> Unit
}
}
)
}
}
@@ -178,7 +134,10 @@ fun TvContinueWatchingCard(
@Composable
fun TvNextUpSection(
items: List<NextUpItem>,
onFocusedItem: (FocusableItem) -> Unit = {},
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
@@ -187,96 +146,36 @@ fun TvNextUpSection(
action = null
)
LazyRow(
modifier = modifier
.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) {
itemsIndexed(
items = items,
key = { _, item -> item.id }
) { index, item ->
TvNextUpCard(
item = item,
isFirstItem = index == 0,
isLastItem = index == items.lastIndex,
onEpisodeSelected = onEpisodeSelected
)
}
}
}
@Composable
fun TvNextUpCard(
item: NextUpItem,
modifier: Modifier = Modifier,
isFirstItem: Boolean = false,
isLastItem: Boolean = false,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
val imageUrl = JellyfinImageHelper.finishImageUrl(item.episode.imageUrlPrefix, ImageType.PRIMARY)
val cardWidth = 280.dp
fun openItem(item: NextUpItem) {
val episode = item.episode
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
Column(
modifier = modifier
.width(cardWidth)
.wrapContentHeight()
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0.5f, 0f)
}
) {
Box(
modifier = Modifier
.width(cardWidth)
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(16.dp))
.border(
width = if (isFocused) 2.dp else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f),
shape = RoundedCornerShape(16.dp)
)
.background(scheme.surfaceVariant)
) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.onFocusChanged { isFocused = it.isFocused }
.clickable {
openItem(item)
},
contentScale = ContentScale.Crop,
)
}
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
text = item.primaryText,
color = scheme.onBackground,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = item.secondaryText,
color = scheme.onSurfaceVariant,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
TvHomeLandscapeCard(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = item.imageUrl,
badgeText = "Next Up",
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(item) },
onClick = {
val episode = item.episode
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
)
}
}
@@ -287,6 +186,9 @@ fun TvLibraryPosterSection(
title: String,
items: List<PosterItem>,
action: String?,
onFocusedItem: (FocusableItem) -> Unit = {},
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
modifier: Modifier = Modifier,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
@@ -297,17 +199,33 @@ fun TvLibraryPosterSection(
action = action
)
LazyRow(
modifier = modifier
.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) {
itemsIndexed(
items = items,
key = { _, item -> item.id }
) { index, item ->
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
PosterCard(
item = item,
posterWidth = TvHomePosterCardWidth,
showSecondaryText = true,
indicatorSize = 24,
indicatorPadding = 6.dp,
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,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected
@@ -324,27 +242,150 @@ fun TvSectionHeader(
onActionClick: () -> Unit = {}
) {
val scheme = MaterialTheme.colorScheme
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = TvHomeSectionsHorizontalPadding, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
color = scheme.onBackground,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold
)
if (action != null) {
Text(
text = action,
color = scheme.primary,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.clickable { onActionClick() })
Box(
modifier = Modifier
.clip(TvHomeSectionsPillShape)
.background(scheme.surfaceContainerHigh.copy(alpha = 0.82f))
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.6f), TvHomeSectionsPillShape)
.clickable { onActionClick() }
.padding(horizontal = 12.dp, vertical = 7.dp),
contentAlignment = Alignment.Center
) {
Text(
text = action,
color = scheme.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
@Composable
private fun TvHomeLandscapeCard(
title: String,
supporting: String,
imageUrl: String?,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
badgeText: String? = null,
progress: Float? = null,
onFocusedItem: () -> Unit = {},
onClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.055f else 1f, label = "tv-home-landscape-scale")
val cardWidth = TvHomeLandscapeCardWidth
Column(
modifier = modifier
.width(cardWidth)
.wrapContentHeight()
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0.5f, 0f)
}
) {
Box(
modifier = Modifier
.width(cardWidth)
.aspectRatio(16f / 9f)
.clip(TvHomeSectionsThumbShape)
.border(
width = if (isFocused) 2.dp else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.6f),
shape = TvHomeSectionsThumbShape
)
.background(scheme.surfaceContainer)
) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = imageModifier
.fillMaxSize()
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocusedItem()
}
}
.clickable(onClick = onClick),
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
scheme.background.copy(alpha = 0.04f),
scheme.background.copy(alpha = 0.12f),
scheme.background.copy(alpha = 0.56f)
)
)
)
)
badgeText?.let { badge ->
TvHomeMetaChip(
text = badge,
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp),
highlighted = isFocused
)
}
if (progress != null && progress > 0f) {
MediaProgressBar(
progress = progress,
foregroundColor = scheme.primary,
backgroundColor = scheme.surfaceVariant,
contentPadding = PaddingValues(0.dp),
barHeight = 6.dp,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 8.dp, vertical = 8.dp)
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 12.dp, start = 4.dp, end = 4.dp)
) {
Text(
text = title,
color = scheme.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (supporting.isNotBlank()) {
Text(
text = supporting,
color = scheme.onSurfaceVariant,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -1,19 +1,44 @@
package hu.bbara.purefin.tv.home.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.BorderStroke
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
internal const val TvHomeTabTagPrefix = "tv-home-tab-"
private val TvHomeTopBarPillShape = RoundedCornerShape(18.dp)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TvHomeTopBar(
tabs: List<TvHomeTabItem>,
@@ -23,29 +48,92 @@ fun TvHomeTopBar(
) {
val scheme = MaterialTheme.colorScheme
val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, tabs.lastIndex.coerceAtLeast(0))
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
PrimaryScrollableTabRow(
selectedTabIndex = safeSelectedTabIndex,
containerColor = scheme.surface,
contentColor = scheme.onSurface,
LaunchedEffect(safeSelectedTabIndex) {
if (tabs.isNotEmpty()) {
listState.animateScrollToItem(safeSelectedTabIndex)
}
}
LazyRow(
state = listState,
modifier = modifier
.fillMaxWidth()
.background(scheme.background),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
tabs.forEachIndexed { index, tab ->
Tab(
selected = index == safeSelectedTabIndex,
onClick = { onTabSelected(index, tab) },
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = tab.icon,
contentDescription = null
)
Text(text = tab.label)
}
}
itemsIndexed(items = tabs) { index, tab ->
var isFocused by remember { mutableStateOf(false) }
val isSelected = index == safeSelectedTabIndex
val containerColor by animateColorAsState(
targetValue = when {
isSelected -> scheme.surfaceContainerHigh
isFocused -> scheme.surfaceContainerHigh.copy(alpha = 0.92f)
else -> scheme.surfaceContainer.copy(alpha = 0.72f)
},
label = "tv-home-tab-container"
)
val contentColor by animateColorAsState(
targetValue = if (isSelected || isFocused) {
scheme.onBackground
} else {
scheme.onSurfaceVariant
},
label = "tv-home-tab-content"
)
val borderColor by animateColorAsState(
targetValue = when {
isFocused -> scheme.primary
isSelected -> scheme.outline
else -> scheme.outlineVariant.copy(alpha = 0.6f)
},
label = "tv-home-tab-border"
)
val scale by animateFloatAsState(
targetValue = if (isFocused) 1.03f else 1f,
label = "tv-home-tab-scale"
)
OutlinedButton(
onClick = { onTabSelected(index, tab) },
modifier = Modifier
.testTag("$TvHomeTabTagPrefix$index")
.semantics { selected = isSelected }
.heightIn(min = 46.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
scope.launch {
listState.animateScrollToItem(index)
}
}
},
shape = TvHomeTopBarPillShape,
border = BorderStroke(1.dp, borderColor),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = containerColor,
contentColor = contentColor
),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 12.dp)
) {
Icon(
imageVector = tab.icon,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = tab.label,
fontSize = 15.sp,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium
)
}
}
}
}

View File

@@ -0,0 +1,140 @@
package hu.bbara.purefin.tv.home.ui
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.util.UUID
class TvFocusedItemHeroTest {
@Test
fun movieItem_mapsProgressMetadataAndBackdrop() {
val movie = sampleMovie(progress = 42.0)
val item = ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = movie
)
val hero = item.toTvFocusedHeroModel()
assertEquals("Continue watching", hero.eyebrowText)
assertEquals("Blade Runner 2049", hero.title)
assertEquals("2017 • 16+ • 164m • 4K", hero.metadataText)
assertEquals(0.42f, hero.progressFraction!!, 0.0001f)
assertEquals("42%", hero.progressLabel)
assertNull(hero.watchedText)
assertEquals(
JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.BACKDROP),
hero.backdropImageUrl
)
}
@Test
fun backdropImageUrl_usesFallbackWhenBackdropPrefixIsMissing() {
assertEquals(
"https://example.com/fallback.jpg",
backdropImageUrl(
imageUrlPrefix = "",
fallbackImageUrl = "https://example.com/fallback.jpg"
)
)
}
@Test
fun episodeItem_prefersWatchedStateOverProgress() {
val item = NextUpItem(
episode = sampleEpisode(
progress = 96.0,
watched = true
)
)
val hero = item.toTvFocusedHeroModel()
assertEquals("Next up", hero.eyebrowText)
assertEquals("The Very Pulse of the Machine", hero.title)
assertEquals("Episode 3 • 2022-05-20 • 16m • 12+ • HD", hero.metadataText)
assertEquals("Watched", hero.watchedText)
assertNull(hero.progressFraction)
assertNull(hero.progressLabel)
}
@Test
fun seriesItem_usesSeasonAndUnwatchedMetadataWithoutProgress() {
val item = PosterItem(
type = BaseItemKind.SERIES,
series = sampleSeries()
)
val hero = item.toTvFocusedHeroModel()
assertEquals("Series", hero.eyebrowText)
assertEquals("Love, Death & Robots", hero.title)
assertEquals("2019 • 3 seasons • 5 unwatched", hero.metadataText)
assertNull(hero.watchedText)
assertNull(hero.progressFraction)
assertNull(hero.progressLabel)
}
private fun sampleMovie(progress: Double? = 42.0): Movie {
return Movie(
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
libraryId = UUID.fromString("22222222-2222-2222-2222-222222222222"),
title = "Blade Runner 2049",
progress = progress,
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://example.com/Items/11111111-1111-1111-1111-111111111111/Images/",
audioTrack = "ENG 5.1",
subtitles = "ENG",
cast = emptyList()
)
}
private fun sampleEpisode(progress: Double?, watched: Boolean): Episode {
return Episode(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
seriesId = UUID.fromString("44444444-4444-4444-4444-444444444444"),
seasonId = UUID.fromString("55555555-5555-5555-5555-555555555555"),
index = 3,
title = "The Very Pulse of the Machine",
synopsis = "An astronaut faces a strange world alone.",
releaseDate = "2022-05-20",
rating = "12+",
runtime = "16m",
progress = progress,
watched = watched,
format = "HD",
imageUrlPrefix = "https://example.com/Items/33333333-3333-3333-3333-333333333333/Images/",
cast = emptyList()
)
}
private fun sampleSeries(): Series {
return Series(
id = UUID.fromString("66666666-6666-6666-6666-666666666666"),
libraryId = UUID.fromString("77777777-7777-7777-7777-777777777777"),
name = "Love, Death & Robots",
synopsis = "Animated sci-fi anthology.",
year = "2019",
imageUrlPrefix = "https://example.com/Items/66666666-6666-6666-6666-666666666666/Images/",
unwatchedEpisodeCount = 5,
seasonCount = 3,
seasons = emptyList(),
cast = emptyList()
)
}
}

View File

@@ -0,0 +1,174 @@
package hu.bbara.purefin.tv.home.ui
import hu.bbara.purefin.core.model.Episode
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.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import java.util.UUID
class TvHomeHeroStateTest {
@Test
fun itemRegistry_prefersContinueWatchingForFirstAvailableItem() {
val continueWatching = continueWatchingItem(
id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049"
)
val nextUp = nextUpItem(id = "22222222-2222-2222-2222-222222222222")
val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333")
val registry = createTvHomeItemRegistry(
libraries = listOf(sampleLibrary(libraryId)),
libraryContent = mapOf(libraryId to listOf(posterItem(id = "44444444-4444-4444-4444-444444444444", libraryId = libraryId))),
continueWatching = listOf(continueWatching),
nextUp = listOf(nextUp)
)
assertEquals(continueWatching.id, registry.firstAvailableItemId)
assertEquals(continueWatching, registry.itemById(continueWatching.id))
}
@Test
fun itemRegistry_fallsBackToNextUpWhenContinueWatchingIsEmpty() {
val nextUp = nextUpItem(id = "22222222-2222-2222-2222-222222222222")
val registry = createTvHomeItemRegistry(
libraries = emptyList(),
libraryContent = emptyMap(),
continueWatching = emptyList(),
nextUp = listOf(nextUp)
)
assertEquals(nextUp.id, registry.firstAvailableItemId)
assertEquals(nextUp, registry.itemById(nextUp.id))
}
@Test
fun itemRegistry_fallsBackToFirstVisibleLibraryItemWhenRowsAreEmpty() {
val firstLibraryId = UUID.fromString("33333333-3333-3333-3333-333333333333")
val firstPoster = posterItem(
id = "44444444-4444-4444-4444-444444444444",
libraryId = firstLibraryId
)
val registry = createTvHomeItemRegistry(
libraries = listOf(
sampleLibrary(firstLibraryId),
sampleLibrary(
id = UUID.fromString("55555555-5555-5555-5555-555555555555"),
isEmpty = true
)
),
libraryContent = mapOf(firstLibraryId to listOf(firstPoster)),
continueWatching = emptyList(),
nextUp = emptyList()
)
assertEquals(firstPoster.id, registry.firstAvailableItemId)
assertEquals(firstPoster, registry.itemById(firstPoster.id))
}
@Test
fun itemRegistry_returnsNullWhenNoItemsAreAvailable() {
val registry = createTvHomeItemRegistry(
libraries = emptyList(),
libraryContent = emptyMap(),
continueWatching = emptyList(),
nextUp = emptyList()
)
assertNull(registry.firstAvailableItemId)
assertNull(registry.firstAvailableItem)
assertNull(registry.itemById(UUID.fromString("99999999-9999-9999-9999-999999999999")))
}
@Test
fun itemRegistry_exposesFirstAvailableItem() {
val continueWatching = continueWatchingItem(
id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049"
)
val registry = createTvHomeItemRegistry(
libraries = emptyList(),
libraryContent = emptyMap(),
continueWatching = listOf(continueWatching),
nextUp = emptyList()
)
assertNotNull(registry.firstAvailableItem)
assertEquals(continueWatching, registry.firstAvailableItem)
}
private fun sampleLibrary(id: UUID, isEmpty: Boolean = false): LibraryItem {
return LibraryItem(
id = id,
name = "Movies",
type = CollectionType.MOVIES,
posterUrl = "",
isEmpty = isEmpty
)
}
private fun continueWatchingItem(id: String, title: String): ContinueWatchingItem {
return ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(id = id, title = title)
)
}
private fun nextUpItem(id: String): NextUpItem {
return NextUpItem(
episode = Episode(
id = UUID.fromString(id),
seriesId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
seasonId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
index = 1,
title = "Next Episode",
synopsis = "Episode synopsis",
releaseDate = "2024-01-01",
rating = "12+",
runtime = "48m",
progress = 10.0,
watched = false,
format = "HD",
imageUrlPrefix = "https://example.com/Items/$id/Images/",
cast = emptyList()
)
)
}
private fun posterItem(id: String, libraryId: UUID): PosterItem {
return PosterItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(id = id, title = "Arrival", libraryId = libraryId)
)
}
private fun sampleMovie(
id: String,
title: String,
libraryId: UUID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc")
): Movie {
return Movie(
id = UUID.fromString(id),
libraryId = libraryId,
title = title,
progress = 25.0,
watched = false,
year = "2017",
rating = "16+",
runtime = "164m",
format = "4K",
synopsis = "Movie synopsis",
imageUrlPrefix = "https://example.com/Items/$id/Images/",
audioTrack = "ENG 5.1",
subtitles = "ENG",
cast = emptyList()
)
}
}

View File

@@ -13,20 +13,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.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.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.shared.home.SuggestedItem
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.UUID
@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)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
val serverUrl: StateFlow<String> = userSessionRepository.serverUrl
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
""
)
val libraries = appContentRepository.libraries.map { libraries ->
libraries.map {
LibraryItem(

View File

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