feat: added TvHomeFocused section to the TvHomeScreen

This commit is contained in:
2026-04-05 19:44:59 +02:00
parent 0011420bf2
commit 69b3933175
16 changed files with 1478 additions and 368 deletions

View File

@@ -1,13 +1,25 @@
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
@@ -15,6 +27,7 @@ import org.junit.Rule
import org.junit.Test
import java.util.UUID
@OptIn(ExperimentalTestApi::class)
class TvHomeContentTest {
@get:Rule
@@ -42,25 +55,120 @@ class TvHomeContentTest {
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 = Movie(
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
libraryId = UUID.fromString("22222222-2222-2222-2222-222222222222"),
movie = sampleMovie(
id = "11111111-1111-1111-1111-111111111111",
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()
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
)
)
)
@@ -85,24 +193,38 @@ class TvHomeContentTest {
libraryId to listOf(
PosterItem(
type = BaseItemKind.MOVIE,
movie = Movie(
id = UUID.fromString("44444444-4444-4444-4444-444444444444"),
libraryId = libraryId,
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 = "2016",
rating = "12+",
runtime = "116m",
year = "2017",
rating = "16+",
runtime = "164m",
format = "4K",
synopsis = "A linguist works to communicate with mysterious visitors.",
imageUrlPrefix = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa",
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,6 +26,7 @@ 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
@@ -41,6 +42,10 @@ 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,
@@ -50,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)
@@ -73,7 +76,7 @@ fun PosterCard(
transformOrigin = TransformOrigin(0.5f, 0f)
}
) {
Box() {
Box {
PurefinAsyncImage(
model = item.imageUrl,
contentDescription = null,
@@ -99,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
)
@@ -109,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 -> {}
}
}
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,
modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp),
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(

View File

@@ -1,21 +1,27 @@
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.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.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 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>,
@@ -28,20 +34,46 @@ fun TvHomeScreen(
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier,
) {
var focusableItem by remember { mutableStateOf<FocusableItem?>(null) }
val scheme = MaterialTheme.colorScheme
val heroState = rememberTvHomeHeroState(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
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 = { item: FocusableItem ->
focusableItem = item
},
onMediaFocused = heroState.onMediaFocused,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected,
modifier = modifier
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,11 +1,13 @@
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
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
@@ -19,7 +21,6 @@ import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem
@@ -40,30 +41,24 @@ fun TvHomeContent(
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
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 = remember(
continueWatching,
nextUp,
firstVisibleLibraryId,
libraryContent
) {
when {
continueWatching.isNotEmpty() -> continueWatching.first().id
nextUp.isNotEmpty() -> nextUp.first().id
firstVisibleLibraryId != null -> libraryContent[firstVisibleLibraryId]?.firstOrNull()?.id
else -> null
}
}
val firstAvailableItemKey = itemRegistry.firstAvailableItemId
var initialFocusApplied by remember { mutableStateOf(false) }
LaunchedEffect(firstAvailableItemKey, initialFocusApplied) {
@@ -77,7 +72,8 @@ fun TvHomeContent(
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(Color.Transparent)
.background(scheme.background),
contentPadding = contentPadding
) {
item {
Spacer(modifier = Modifier.height(8.dp))
@@ -97,7 +93,7 @@ fun TvHomeContent(
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(20.dp))
}
item {
TvNextUpSection(
@@ -116,7 +112,7 @@ fun TvHomeContent(
)
}
item {
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(20.dp))
}
items(
items = visibleLibraries,
@@ -148,7 +144,7 @@ fun TvHomeContent(
?.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

@@ -31,6 +31,7 @@ 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
@@ -50,7 +51,13 @@ 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(
@@ -68,14 +75,30 @@ 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, key = { _, item -> item.id }) { index, item ->
TvContinueWatchingCard(
item = 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
)
BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
prefixImageUrl = item.episode?.imageUrlPrefix,
imageType = ImageType.PRIMARY
)
else -> null
},
badgeText = if (progressFraction > 0f) "Resume" else null,
progress = progressFraction,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
@@ -91,43 +114,8 @@ fun TvContinueWatchingSection(
Modifier
}
),
onFocusedItem = onFocusedItem,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected
)
}
}
}
@Composable
fun TvContinueWatchingCard(
item: ContinueWatchingItem,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
onFocusedItem: (FocusableItem) -> Unit = {},
onMovieSelected: (UUID) -> Unit,
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 = 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) {
onFocusedItem = { onFocusedItem(item) },
onClick = {
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
BaseItemKind.EPISODE -> {
@@ -135,71 +123,9 @@ fun TvContinueWatchingCard(
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
else -> {}
else -> Unit
}
}
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 = imageModifier
.fillMaxSize()
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocusedItem(item)
}
}
.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
)
}
}
@@ -220,14 +146,16 @@ 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,
TvHomeLandscapeCard(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = item.imageUrl,
badgeText = "Next Up",
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
@@ -243,89 +171,11 @@ fun TvNextUpSection(
Modifier
}
),
onFocusedItem = onFocusedItem,
onEpisodeSelected = onEpisodeSelected
)
}
}
}
@Composable
fun TvNextUpCard(
item: NextUpItem,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
onFocusedItem: (FocusableItem) -> Unit = {},
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 = item.imageUrl
val cardWidth = 280.dp
fun openItem(item: NextUpItem) {
onFocusedItem = { onFocusedItem(item) },
onClick = {
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 = imageModifier
.fillMaxSize()
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocusedItem(item)
}
}
.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
)
}
}
@@ -349,14 +199,17 @@ 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 ->
PosterCard(
item = item,
posterWidth = TvHomePosterCardWidth,
showSecondaryText = true,
indicatorSize = 24,
indicatorPadding = 6.dp,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
@@ -389,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) {
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.primary,
fontSize = 14.sp,
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,
modifier = Modifier.clickable { onActionClick() })
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,
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) },
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
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
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(text = tab.label)
}
}
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()
)
}
}