mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat: added TvHomeFocused section to the TvHomeScreen
This commit is contained in:
@@ -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,
|
||||
watched = false,
|
||||
year = "2016",
|
||||
rating = "12+",
|
||||
runtime = "116m",
|
||||
format = "4K",
|
||||
synopsis = "A linguist works to communicate with mysterious visitors.",
|
||||
imageUrlPrefix = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa",
|
||||
audioTrack = "ENG 5.1",
|
||||
subtitles = "ENG",
|
||||
cast = emptyList()
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
|
||||
TvHomeContent(
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val heroState = rememberTvHomeHeroState(
|
||||
libraries = libraries,
|
||||
libraryContent = libraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
onMediaFocused = { item: FocusableItem ->
|
||||
focusableItem = item
|
||||
},
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ( nextUpRef, continueWatchingRef ) = remember { FocusRequester.createRefs() }
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,115 +114,18 @@ 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) {
|
||||
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 = imageModifier
|
||||
.fillMaxSize()
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) {
|
||||
onFocusedItem(item)
|
||||
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)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
.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) {
|
||||
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
|
||||
onFocusedItem = { onFocusedItem(item) },
|
||||
onClick = {
|
||||
val episode = item.episode
|
||||
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user