mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
2 Commits
584e430431
...
69b3933175
| Author | SHA1 | Date | |
|---|---|---|---|
| 69b3933175 | |||
| 0011420bf2 |
@@ -0,0 +1,230 @@
|
|||||||
|
package hu.bbara.purefin.tv.home.ui
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import hu.bbara.purefin.core.model.Movie
|
||||||
|
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||||
|
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||||
|
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||||
|
import hu.bbara.purefin.tv.home.TvHomeScreen
|
||||||
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class TvHomeContentTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tvHomeContent_focusesFirstAvailableItemByDefault() {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
TvHomeContent(
|
||||||
|
libraries = sampleLibraries(),
|
||||||
|
libraryContent = sampleLibraryContent(),
|
||||||
|
continueWatching = sampleContinueWatching(),
|
||||||
|
nextUp = emptyList(),
|
||||||
|
onMediaFocused = {},
|
||||||
|
onMovieSelected = {},
|
||||||
|
onSeriesSelected = {},
|
||||||
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tvHomeScreen_showsHeroForInitiallyFocusedItem() {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
TvHomeScreen(
|
||||||
|
libraries = sampleLibraries(),
|
||||||
|
libraryContent = sampleLibraryContent(),
|
||||||
|
continueWatching = sampleContinueWatching(),
|
||||||
|
nextUp = emptyList(),
|
||||||
|
serverUrl = "",
|
||||||
|
onMovieSelected = {},
|
||||||
|
onSeriesSelected = {},
|
||||||
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
|
||||||
|
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
|
||||||
|
composeRule.onNodeWithTag(TvHomeHeroProgressLabelTag).assertTextEquals("42%")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tvHomeScreen_updatesHeroWhenFocusMovesWithinRow() {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
TvHomeScreen(
|
||||||
|
libraries = sampleLibraries(),
|
||||||
|
libraryContent = sampleLibraryContent(),
|
||||||
|
continueWatching = sampleContinueWatchingRow(),
|
||||||
|
nextUp = emptyList(),
|
||||||
|
serverUrl = "",
|
||||||
|
onMovieSelected = {},
|
||||||
|
onSeriesSelected = {},
|
||||||
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
|
||||||
|
composeRule.onNodeWithTag(TvHomeInitialFocusTag)
|
||||||
|
.assertIsFocused()
|
||||||
|
.performKeyInput {
|
||||||
|
pressKey(Key.DirectionRight)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Mad Max: Fury Road")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tvHomeScreen_compactHeroKeepsFirstRowVisible() {
|
||||||
|
composeRule.setContent {
|
||||||
|
AppTheme {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(width = 960.dp, height = 540.dp)
|
||||||
|
) {
|
||||||
|
TvHomeScreen(
|
||||||
|
libraries = sampleLibraries(),
|
||||||
|
libraryContent = sampleLibraryContent(),
|
||||||
|
continueWatching = sampleContinueWatching(),
|
||||||
|
nextUp = emptyList(),
|
||||||
|
serverUrl = "",
|
||||||
|
onMovieSelected = {},
|
||||||
|
onSeriesSelected = {},
|
||||||
|
onEpisodeSelected = { _, _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag(TvHomeInitialFocusTag)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
.assertIsFocused()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleContinueWatching(): List<ContinueWatchingItem> {
|
||||||
|
return listOf(
|
||||||
|
ContinueWatchingItem(
|
||||||
|
type = BaseItemKind.MOVIE,
|
||||||
|
movie = sampleMovie(
|
||||||
|
id = "11111111-1111-1111-1111-111111111111",
|
||||||
|
title = "Blade Runner 2049",
|
||||||
|
progress = 42.0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleContinueWatchingRow(): List<ContinueWatchingItem> {
|
||||||
|
return listOf(
|
||||||
|
ContinueWatchingItem(
|
||||||
|
type = BaseItemKind.MOVIE,
|
||||||
|
movie = sampleMovie(
|
||||||
|
id = "11111111-1111-1111-1111-111111111111",
|
||||||
|
title = "Blade Runner 2049",
|
||||||
|
progress = 42.0
|
||||||
|
)
|
||||||
|
),
|
||||||
|
ContinueWatchingItem(
|
||||||
|
type = BaseItemKind.MOVIE,
|
||||||
|
movie = sampleMovie(
|
||||||
|
id = "55555555-5555-5555-5555-555555555555",
|
||||||
|
title = "Mad Max: Fury Road",
|
||||||
|
progress = 8.0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleLibraries(): List<LibraryItem> {
|
||||||
|
return listOf(
|
||||||
|
LibraryItem(
|
||||||
|
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
||||||
|
name = "Movies",
|
||||||
|
type = CollectionType.MOVIES,
|
||||||
|
posterUrl = "",
|
||||||
|
isEmpty = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
|
||||||
|
val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333")
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
libraryId to listOf(
|
||||||
|
PosterItem(
|
||||||
|
type = BaseItemKind.MOVIE,
|
||||||
|
movie = sampleMovie(
|
||||||
|
id = "44444444-4444-4444-4444-444444444444",
|
||||||
|
title = "Arrival",
|
||||||
|
progress = null,
|
||||||
|
libraryId = libraryId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleMovie(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
progress: Double?,
|
||||||
|
libraryId: UUID = UUID.fromString("22222222-2222-2222-2222-222222222222")
|
||||||
|
): Movie {
|
||||||
|
return Movie(
|
||||||
|
id = UUID.fromString(id),
|
||||||
|
libraryId = libraryId,
|
||||||
|
title = title,
|
||||||
|
progress = progress,
|
||||||
|
watched = false,
|
||||||
|
year = "2017",
|
||||||
|
rating = "16+",
|
||||||
|
runtime = "164m",
|
||||||
|
format = "4K",
|
||||||
|
synopsis = "Officer K uncovers a secret that sends him searching for Rick Deckard.",
|
||||||
|
imageUrlPrefix = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
|
||||||
|
audioTrack = "ENG 5.1",
|
||||||
|
subtitles = "ENG",
|
||||||
|
cast = emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,11 +26,13 @@ import androidx.compose.ui.graphics.graphicsLayer
|
|||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||||
import hu.bbara.purefin.common.ui.components.UnwatchedEpisodeIndicator
|
import hu.bbara.purefin.common.ui.components.UnwatchedEpisodeIndicator
|
||||||
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
|
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
|
||||||
|
import hu.bbara.purefin.feature.shared.home.FocusableItem
|
||||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
@@ -39,6 +41,12 @@ import org.jellyfin.sdk.model.api.BaseItemKind
|
|||||||
fun PosterCard(
|
fun PosterCard(
|
||||||
item: PosterItem,
|
item: PosterItem,
|
||||||
modifier: Modifier = Modifier,
|
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,
|
onMovieSelected: (UUID) -> Unit,
|
||||||
onSeriesSelected: (UUID) -> Unit,
|
onSeriesSelected: (UUID) -> Unit,
|
||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||||
@@ -47,8 +55,6 @@ fun PosterCard(
|
|||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
||||||
|
|
||||||
val posterWidth = 144.dp
|
|
||||||
|
|
||||||
fun openItem(posterItem: PosterItem) {
|
fun openItem(posterItem: PosterItem) {
|
||||||
when (posterItem.type) {
|
when (posterItem.type) {
|
||||||
BaseItemKind.MOVIE -> onMovieSelected(posterItem.id)
|
BaseItemKind.MOVIE -> onMovieSelected(posterItem.id)
|
||||||
@@ -70,11 +76,11 @@ fun PosterCard(
|
|||||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Box() {
|
Box {
|
||||||
PurefinAsyncImage(
|
PurefinAsyncImage(
|
||||||
model = item.imageUrl,
|
model = item.imageUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = imageModifier
|
||||||
.aspectRatio(2f / 3f)
|
.aspectRatio(2f / 3f)
|
||||||
.clip(RoundedCornerShape(14.dp))
|
.clip(RoundedCornerShape(14.dp))
|
||||||
.border(
|
.border(
|
||||||
@@ -83,7 +89,12 @@ fun PosterCard(
|
|||||||
shape = RoundedCornerShape(14.dp)
|
shape = RoundedCornerShape(14.dp)
|
||||||
)
|
)
|
||||||
.background(scheme.surfaceVariant)
|
.background(scheme.surfaceVariant)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged {
|
||||||
|
isFocused = it.isFocused
|
||||||
|
if (it.isFocused) {
|
||||||
|
onFocusedItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
.clickable(onClick = { openItem(item) }),
|
.clickable(onClick = { openItem(item) }),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
@@ -91,9 +102,9 @@ fun PosterCard(
|
|||||||
BaseItemKind.MOVIE -> {
|
BaseItemKind.MOVIE -> {
|
||||||
val m = item.movie!!
|
val m = item.movie!!
|
||||||
WatchStateIndicator(
|
WatchStateIndicator(
|
||||||
size = 28,
|
size = indicatorSize,
|
||||||
modifier = Modifier.align(Alignment.TopEnd)
|
modifier = Modifier.align(Alignment.TopEnd)
|
||||||
.padding(8.dp),
|
.padding(indicatorPadding),
|
||||||
watched = m.watched,
|
watched = m.watched,
|
||||||
started = (m.progress ?: 0.0) > 0
|
started = (m.progress ?: 0.0) > 0
|
||||||
)
|
)
|
||||||
@@ -101,30 +112,45 @@ fun PosterCard(
|
|||||||
BaseItemKind.EPISODE -> {
|
BaseItemKind.EPISODE -> {
|
||||||
val ep = item.episode!!
|
val ep = item.episode!!
|
||||||
WatchStateIndicator(
|
WatchStateIndicator(
|
||||||
size = 28,
|
size = indicatorSize,
|
||||||
modifier = Modifier.align(Alignment.TopEnd)
|
modifier = Modifier.align(Alignment.TopEnd)
|
||||||
.padding(8.dp),
|
.padding(indicatorPadding),
|
||||||
watched = ep.watched,
|
watched = ep.watched,
|
||||||
started = (ep.progress ?: 0.0) > 0
|
started = (ep.progress ?: 0.0) > 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
BaseItemKind.SERIES -> UnwatchedEpisodeIndicator(
|
BaseItemKind.SERIES -> UnwatchedEpisodeIndicator(
|
||||||
size = 28,
|
size = indicatorSize,
|
||||||
modifier = Modifier.align(Alignment.TopEnd)
|
modifier = Modifier.align(Alignment.TopEnd)
|
||||||
.padding(8.dp),
|
.padding(indicatorPadding),
|
||||||
unwatchedCount = item.series!!.unwatchedEpisodeCount
|
unwatchedCount = item.series!!.unwatchedEpisodeCount
|
||||||
)
|
)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Column(
|
||||||
text = item.title,
|
modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp)
|
||||||
color = scheme.onBackground,
|
) {
|
||||||
fontSize = 13.sp,
|
Text(
|
||||||
fontWeight = FontWeight.Medium,
|
text = item.title,
|
||||||
modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp),
|
color = scheme.onBackground,
|
||||||
maxLines = 1,
|
fontSize = 13.sp,
|
||||||
overflow = TextOverflow.Ellipsis
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -61,7 +60,7 @@ internal fun TvMediaDetailScaffold(
|
|||||||
heightFraction = heroHeightFraction,
|
heightFraction = heroHeightFraction,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
MediaDetailHeroGradientOverlay()
|
MediaHeroScrimOverlay()
|
||||||
Column(
|
Column(
|
||||||
modifier = contentPadding
|
modifier = contentPadding
|
||||||
.padding(top = 104.dp, bottom = 36.dp)
|
.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.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -12,6 +13,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,15 +29,18 @@ fun MediaProgressBar(
|
|||||||
progress: Float,
|
progress: Float,
|
||||||
foregroundColor: Color = MaterialTheme.colorScheme.onSurface,
|
foregroundColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||||
backgroundColor: Color = MaterialTheme.colorScheme.primary,
|
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
|
modifier: Modifier
|
||||||
) {
|
) {
|
||||||
if (progress == 0f) return
|
if (progress <= 0f) return
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(bottom = 8.dp, start = 8.dp, end = 8.dp)
|
.padding(contentPadding)
|
||||||
.clip(RoundedCornerShape(24.dp))
|
.clip(RoundedCornerShape(cornerRadius))
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(4.dp)
|
.height(barHeight)
|
||||||
.background(backgroundColor.copy(alpha = 0.2f))
|
.background(backgroundColor.copy(alpha = 0.2f))
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -45,4 +50,4 @@ fun MediaProgressBar(
|
|||||||
.background(foregroundColor)
|
.background(foregroundColor)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,13 +35,15 @@ fun TvAppScreen(
|
|||||||
libraryViewModel: LibraryViewModel = hiltViewModel(),
|
libraryViewModel: LibraryViewModel = hiltViewModel(),
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var selectedTabIndex by remember { mutableIntStateOf(1) }
|
val serverUrl by viewModel.serverUrl.collectAsState()
|
||||||
val libraries by viewModel.libraries.collectAsState()
|
val libraries by viewModel.libraries.collectAsState()
|
||||||
val continueWatching by viewModel.continueWatching.collectAsState()
|
val continueWatching by viewModel.continueWatching.collectAsState()
|
||||||
val nextUp by viewModel.nextUp.collectAsState()
|
val nextUp by viewModel.nextUp.collectAsState()
|
||||||
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState()
|
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState()
|
||||||
val selectedLibraryItems by libraryViewModel.contents.collectAsState()
|
val selectedLibraryItems by libraryViewModel.contents.collectAsState()
|
||||||
|
|
||||||
|
var selectedTabIndex by remember { mutableIntStateOf(1) }
|
||||||
|
|
||||||
val tabs = remember(libraries) {
|
val tabs = remember(libraries) {
|
||||||
buildList {
|
buildList {
|
||||||
add(
|
add(
|
||||||
@@ -125,6 +127,7 @@ fun TvAppScreen(
|
|||||||
libraryContent = latestLibraryContent,
|
libraryContent = latestLibraryContent,
|
||||||
continueWatching = continueWatching,
|
continueWatching = continueWatching,
|
||||||
nextUp = nextUp,
|
nextUp = nextUp,
|
||||||
|
serverUrl = serverUrl,
|
||||||
onMovieSelected = viewModel::onMovieSelected,
|
onMovieSelected = viewModel::onMovieSelected,
|
||||||
onSeriesSelected = viewModel::onSeriesSelected,
|
onSeriesSelected = viewModel::onSeriesSelected,
|
||||||
onEpisodeSelected = viewModel::onEpisodeSelected,
|
onEpisodeSelected = viewModel::onEpisodeSelected,
|
||||||
|
|||||||
@@ -1,35 +1,79 @@
|
|||||||
package hu.bbara.purefin.tv.home
|
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.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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.ContinueWatchingItem
|
||||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||||
|
import hu.bbara.purefin.tv.home.ui.TvFocusedItemHero
|
||||||
import hu.bbara.purefin.tv.home.ui.TvHomeContent
|
import hu.bbara.purefin.tv.home.ui.TvHomeContent
|
||||||
|
import hu.bbara.purefin.tv.home.ui.rememberTvHomeHeroState
|
||||||
import org.jellyfin.sdk.model.UUID
|
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
|
@Composable
|
||||||
fun TvHomeScreen(
|
fun TvHomeScreen(
|
||||||
libraries: List<LibraryItem>,
|
libraries: List<LibraryItem>,
|
||||||
libraryContent: Map<UUID, List<PosterItem>>,
|
libraryContent: Map<UUID, List<PosterItem>>,
|
||||||
continueWatching: List<ContinueWatchingItem>,
|
continueWatching: List<ContinueWatchingItem>,
|
||||||
nextUp: List<NextUpItem>,
|
nextUp: List<NextUpItem>,
|
||||||
|
serverUrl: String,
|
||||||
onMovieSelected: (UUID) -> Unit,
|
onMovieSelected: (UUID) -> Unit,
|
||||||
onSeriesSelected: (UUID) -> Unit,
|
onSeriesSelected: (UUID) -> Unit,
|
||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
TvHomeContent(
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val heroState = rememberTvHomeHeroState(
|
||||||
libraries = libraries,
|
libraries = libraries,
|
||||||
libraryContent = libraryContent,
|
libraryContent = libraryContent,
|
||||||
continueWatching = continueWatching,
|
continueWatching = continueWatching,
|
||||||
nextUp = nextUp,
|
nextUp = nextUp
|
||||||
onMovieSelected = onMovieSelected,
|
|
||||||
onSeriesSelected = onSeriesSelected,
|
|
||||||
onEpisodeSelected = onEpisodeSelected,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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,6 +1,7 @@
|
|||||||
package hu.bbara.purefin.tv.home.ui
|
package hu.bbara.purefin.tv.home.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -8,7 +9,12 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
|
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
|
||||||
@@ -17,35 +23,57 @@ import androidx.compose.ui.focus.focusProperties
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||||
|
import hu.bbara.purefin.feature.shared.home.FocusableItem
|
||||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
|
||||||
|
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TvHomeContent(
|
fun TvHomeContent(
|
||||||
libraries: List<LibraryItem>,
|
libraries: List<LibraryItem>,
|
||||||
libraryContent: Map<UUID, List<PosterItem>>,
|
libraryContent: Map<UUID, List<PosterItem>>,
|
||||||
continueWatching: List<ContinueWatchingItem>,
|
continueWatching: List<ContinueWatchingItem>,
|
||||||
nextUp: List<NextUpItem>,
|
nextUp: List<NextUpItem>,
|
||||||
|
onMediaFocused: (FocusableItem) -> Unit,
|
||||||
onMovieSelected: (UUID) -> Unit,
|
onMovieSelected: (UUID) -> Unit,
|
||||||
onSeriesSelected: (UUID) -> Unit,
|
onSeriesSelected: (UUID) -> Unit,
|
||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val visibleLibraries = remember(libraries, libraryContent) {
|
val scheme = MaterialTheme.colorScheme
|
||||||
libraries.filter { libraryContent[it.id]?.isEmpty() != true }
|
val itemRegistry = rememberTvHomeItemRegistry(
|
||||||
|
libraries = libraries,
|
||||||
|
libraryContent = libraryContent,
|
||||||
|
continueWatching = continueWatching,
|
||||||
|
nextUp = nextUp
|
||||||
|
)
|
||||||
|
val visibleLibraries = itemRegistry.visibleLibraries
|
||||||
|
val (nextUpRef, continueWatchingRef) = remember { FocusRequester.createRefs() }
|
||||||
|
val libraryRefs = remember(visibleLibraries) {
|
||||||
|
visibleLibraries.associate { it.id to FocusRequester() }
|
||||||
}
|
}
|
||||||
|
val initialFocusRequester = remember { FocusRequester() }
|
||||||
|
val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id
|
||||||
|
val firstAvailableItemKey = itemRegistry.firstAvailableItemId
|
||||||
|
var initialFocusApplied by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val ( nextUpRef, continueWatchingRef ) = remember { FocusRequester.createRefs() }
|
LaunchedEffect(firstAvailableItemKey, initialFocusApplied) {
|
||||||
val libraryRefs = remember(libraryContent) {
|
if (!initialFocusApplied && firstAvailableItemKey != null) {
|
||||||
libraryContent.keys.associateWith { FocusRequester() }
|
withFrameNanos { }
|
||||||
|
initialFocusRequester.requestFocus()
|
||||||
|
initialFocusApplied = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(scheme.background),
|
||||||
|
contentPadding = contentPadding
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -53,8 +81,11 @@ fun TvHomeContent(
|
|||||||
item {
|
item {
|
||||||
TvContinueWatchingSection(
|
TvContinueWatchingSection(
|
||||||
items = continueWatching,
|
items = continueWatching,
|
||||||
|
onFocusedItem = onMediaFocused,
|
||||||
onMovieSelected = onMovieSelected,
|
onMovieSelected = onMovieSelected,
|
||||||
onEpisodeSelected = onEpisodeSelected,
|
onEpisodeSelected = onEpisodeSelected,
|
||||||
|
firstItemFocusRequester = initialFocusRequester.takeIf { continueWatching.isNotEmpty() },
|
||||||
|
firstItemTestTag = TvHomeInitialFocusTag.takeIf { continueWatching.isNotEmpty() },
|
||||||
modifier = Modifier.focusRequester(continueWatchingRef)
|
modifier = Modifier.focusRequester(continueWatchingRef)
|
||||||
.focusProperties {
|
.focusProperties {
|
||||||
down = nextUpRef
|
down = nextUpRef
|
||||||
@@ -62,12 +93,17 @@ fun TvHomeContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
TvNextUpSection(
|
TvNextUpSection(
|
||||||
items = nextUp,
|
items = nextUp,
|
||||||
|
onFocusedItem = onMediaFocused,
|
||||||
onEpisodeSelected = onEpisodeSelected,
|
onEpisodeSelected = onEpisodeSelected,
|
||||||
|
firstItemFocusRequester = initialFocusRequester
|
||||||
|
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
|
||||||
|
firstItemTestTag = TvHomeInitialFocusTag
|
||||||
|
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
|
||||||
modifier = Modifier.focusRequester(nextUpRef)
|
modifier = Modifier.focusRequester(nextUpRef)
|
||||||
.focusProperties {
|
.focusProperties {
|
||||||
up = continueWatchingRef
|
up = continueWatchingRef
|
||||||
@@ -76,7 +112,7 @@ fun TvHomeContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
}
|
}
|
||||||
items(
|
items(
|
||||||
items = visibleLibraries,
|
items = visibleLibraries,
|
||||||
@@ -87,17 +123,28 @@ fun TvHomeContent(
|
|||||||
title = item.name,
|
title = item.name,
|
||||||
items = libraryContent[item.id] ?: emptyList(),
|
items = libraryContent[item.id] ?: emptyList(),
|
||||||
action = "See All",
|
action = "See All",
|
||||||
|
onFocusedItem = onMediaFocused,
|
||||||
onMovieSelected = onMovieSelected,
|
onMovieSelected = onMovieSelected,
|
||||||
onSeriesSelected = onSeriesSelected,
|
onSeriesSelected = onSeriesSelected,
|
||||||
onEpisodeSelected = onEpisodeSelected,
|
onEpisodeSelected = onEpisodeSelected,
|
||||||
modifier = if (ref != null) Modifier.focusRequester(ref) else Modifier
|
firstItemFocusRequester = initialFocusRequester.takeIf {
|
||||||
|
continueWatching.isEmpty() &&
|
||||||
|
nextUp.isEmpty() &&
|
||||||
|
item.id == firstVisibleLibraryId
|
||||||
|
},
|
||||||
|
firstItemTestTag = TvHomeInitialFocusTag.takeIf {
|
||||||
|
continueWatching.isEmpty() &&
|
||||||
|
nextUp.isEmpty() &&
|
||||||
|
item.id == firstVisibleLibraryId
|
||||||
|
},
|
||||||
|
modifier = (if (ref != null) Modifier.focusRequester(ref) else Modifier)
|
||||||
.focusProperties {
|
.focusProperties {
|
||||||
up = nextUpRef
|
up = nextUpRef
|
||||||
libraryRefs.values.dropWhile { it != ref }.drop(1).firstOrNull()
|
libraryRefs.values.dropWhile { it != ref }.drop(1).firstOrNull()
|
||||||
?.let { down = it }
|
?.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,10 +28,14 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -41,18 +45,28 @@ import hu.bbara.purefin.common.ui.components.MediaProgressBar
|
|||||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||||
|
import hu.bbara.purefin.feature.shared.home.FocusableItem
|
||||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
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
|
@Composable
|
||||||
fun TvContinueWatchingSection(
|
fun TvContinueWatchingSection(
|
||||||
items: List<ContinueWatchingItem>,
|
items: List<ContinueWatchingItem>,
|
||||||
|
onFocusedItem: (FocusableItem) -> Unit = {},
|
||||||
onMovieSelected: (UUID) -> Unit,
|
onMovieSelected: (UUID) -> Unit,
|
||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||||
|
firstItemFocusRequester: FocusRequester? = null,
|
||||||
|
firstItemTestTag: String? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty()) return
|
||||||
@@ -61,115 +75,57 @@ fun TvContinueWatchingSection(
|
|||||||
action = null
|
action = null
|
||||||
)
|
)
|
||||||
LazyRow(
|
LazyRow(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth(),
|
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items = items) { index, item ->
|
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||||
TvContinueWatchingCard(
|
val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f)
|
||||||
item = item,
|
TvHomeLandscapeCard(
|
||||||
onMovieSelected = onMovieSelected,
|
title = item.primaryText,
|
||||||
onEpisodeSelected = onEpisodeSelected
|
supporting = item.secondaryText,
|
||||||
)
|
imageUrl = when (item.type) {
|
||||||
}
|
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
|
||||||
}
|
prefixImageUrl = item.movie?.imageUrlPrefix,
|
||||||
}
|
imageType = ImageType.PRIMARY
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
|
||||||
fun TvContinueWatchingCard(
|
prefixImageUrl = item.episode?.imageUrlPrefix,
|
||||||
item: ContinueWatchingItem,
|
imageType = ImageType.PRIMARY
|
||||||
modifier: Modifier = Modifier,
|
)
|
||||||
onMovieSelected: (UUID) -> Unit,
|
|
||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
|
||||||
) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
|
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
else -> null
|
||||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
},
|
||||||
|
badgeText = if (progressFraction > 0f) "Resume" else null,
|
||||||
|
progress = progressFraction,
|
||||||
|
imageModifier = Modifier
|
||||||
|
.then(
|
||||||
|
if (index == 0 && firstItemFocusRequester != null) {
|
||||||
|
Modifier.focusRequester(firstItemFocusRequester)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (index == 0 && firstItemTestTag != null) {
|
||||||
|
Modifier.testTag(firstItemTestTag)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
),
|
||||||
|
onFocusedItem = { onFocusedItem(item) },
|
||||||
|
onClick = {
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
|
||||||
|
BaseItemKind.EPISODE -> {
|
||||||
|
val episode = item.episode!!
|
||||||
|
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
||||||
|
}
|
||||||
|
|
||||||
val imageUrl = when (item.type) {
|
else -> Unit
|
||||||
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
|
}
|
||||||
prefixImageUrl = item.movie?.imageUrlPrefix,
|
}
|
||||||
imageType = ImageType.PRIMARY
|
|
||||||
)
|
|
||||||
BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
|
|
||||||
prefixImageUrl = item.episode?.imageUrlPrefix,
|
|
||||||
imageType = ImageType.PRIMARY
|
|
||||||
)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
val cardWidth = 280.dp
|
|
||||||
|
|
||||||
fun openItem(item: ContinueWatchingItem) {
|
|
||||||
when (item.type) {
|
|
||||||
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
|
|
||||||
BaseItemKind.EPISODE -> {
|
|
||||||
val episode = item.episode!!
|
|
||||||
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.width(cardWidth)
|
|
||||||
.wrapContentHeight()
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = scale
|
|
||||||
scaleY = scale
|
|
||||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(cardWidth)
|
|
||||||
.aspectRatio(16f / 9f)
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.border(
|
|
||||||
width = if (isFocused) 2.dp else 1.dp,
|
|
||||||
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
)
|
|
||||||
.background(scheme.surfaceVariant)
|
|
||||||
) {
|
|
||||||
PurefinAsyncImage(
|
|
||||||
model = imageUrl,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
|
||||||
.clickable {
|
|
||||||
openItem(item)
|
|
||||||
},
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
MediaProgressBar(
|
|
||||||
progress = item.progress.toFloat().nextUp().div(100),
|
|
||||||
foregroundColor = scheme.onSurface,
|
|
||||||
backgroundColor = scheme.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = item.primaryText,
|
|
||||||
color = scheme.onBackground,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = item.secondaryText,
|
|
||||||
color = scheme.onSurfaceVariant,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +134,10 @@ fun TvContinueWatchingCard(
|
|||||||
@Composable
|
@Composable
|
||||||
fun TvNextUpSection(
|
fun TvNextUpSection(
|
||||||
items: List<NextUpItem>,
|
items: List<NextUpItem>,
|
||||||
|
onFocusedItem: (FocusableItem) -> Unit = {},
|
||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||||
|
firstItemFocusRequester: FocusRequester? = null,
|
||||||
|
firstItemTestTag: String? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty()) return
|
||||||
@@ -187,96 +146,36 @@ fun TvNextUpSection(
|
|||||||
action = null
|
action = null
|
||||||
)
|
)
|
||||||
LazyRow(
|
LazyRow(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth(),
|
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||||
items = items,
|
TvHomeLandscapeCard(
|
||||||
key = { _, item -> item.id }
|
title = item.primaryText,
|
||||||
) { index, item ->
|
supporting = item.secondaryText,
|
||||||
TvNextUpCard(
|
imageUrl = item.imageUrl,
|
||||||
item = item,
|
badgeText = "Next Up",
|
||||||
isFirstItem = index == 0,
|
imageModifier = Modifier
|
||||||
isLastItem = index == items.lastIndex,
|
.then(
|
||||||
onEpisodeSelected = onEpisodeSelected
|
if (index == 0 && firstItemFocusRequester != null) {
|
||||||
)
|
Modifier.focusRequester(firstItemFocusRequester)
|
||||||
}
|
} else {
|
||||||
}
|
Modifier
|
||||||
}
|
}
|
||||||
|
)
|
||||||
@Composable
|
.then(
|
||||||
fun TvNextUpCard(
|
if (index == 0 && firstItemTestTag != null) {
|
||||||
item: NextUpItem,
|
Modifier.testTag(firstItemTestTag)
|
||||||
modifier: Modifier = Modifier,
|
} else {
|
||||||
isFirstItem: Boolean = false,
|
Modifier
|
||||||
isLastItem: Boolean = false,
|
}
|
||||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
),
|
||||||
) {
|
onFocusedItem = { onFocusedItem(item) },
|
||||||
val scheme = MaterialTheme.colorScheme
|
onClick = {
|
||||||
|
val episode = item.episode
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
||||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
}
|
||||||
|
|
||||||
val imageUrl = JellyfinImageHelper.finishImageUrl(item.episode.imageUrlPrefix, ImageType.PRIMARY)
|
|
||||||
|
|
||||||
val cardWidth = 280.dp
|
|
||||||
|
|
||||||
fun openItem(item: NextUpItem) {
|
|
||||||
val episode = item.episode
|
|
||||||
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.width(cardWidth)
|
|
||||||
.wrapContentHeight()
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = scale
|
|
||||||
scaleY = scale
|
|
||||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(cardWidth)
|
|
||||||
.aspectRatio(16f / 9f)
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.border(
|
|
||||||
width = if (isFocused) 2.dp else 1.dp,
|
|
||||||
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f),
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
)
|
|
||||||
.background(scheme.surfaceVariant)
|
|
||||||
) {
|
|
||||||
PurefinAsyncImage(
|
|
||||||
model = imageUrl,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
|
||||||
.clickable {
|
|
||||||
openItem(item)
|
|
||||||
},
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = item.primaryText,
|
|
||||||
color = scheme.onBackground,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = item.secondaryText,
|
|
||||||
color = scheme.onSurfaceVariant,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,6 +186,9 @@ fun TvLibraryPosterSection(
|
|||||||
title: String,
|
title: String,
|
||||||
items: List<PosterItem>,
|
items: List<PosterItem>,
|
||||||
action: String?,
|
action: String?,
|
||||||
|
onFocusedItem: (FocusableItem) -> Unit = {},
|
||||||
|
firstItemFocusRequester: FocusRequester? = null,
|
||||||
|
firstItemTestTag: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onMovieSelected: (UUID) -> Unit,
|
onMovieSelected: (UUID) -> Unit,
|
||||||
onSeriesSelected: (UUID) -> Unit,
|
onSeriesSelected: (UUID) -> Unit,
|
||||||
@@ -297,17 +199,33 @@ fun TvLibraryPosterSection(
|
|||||||
action = action
|
action = action
|
||||||
)
|
)
|
||||||
LazyRow(
|
LazyRow(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth(),
|
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
|
||||||
items = items,
|
|
||||||
key = { _, item -> item.id }
|
|
||||||
) { index, item ->
|
|
||||||
PosterCard(
|
PosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
|
posterWidth = TvHomePosterCardWidth,
|
||||||
|
showSecondaryText = true,
|
||||||
|
indicatorSize = 24,
|
||||||
|
indicatorPadding = 6.dp,
|
||||||
|
imageModifier = Modifier
|
||||||
|
.then(
|
||||||
|
if (index == 0 && firstItemFocusRequester != null) {
|
||||||
|
Modifier.focusRequester(firstItemFocusRequester)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (index == 0 && firstItemTestTag != null) {
|
||||||
|
Modifier.testTag(firstItemTestTag)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
),
|
||||||
|
onFocusedItem = onFocusedItem,
|
||||||
onMovieSelected = onMovieSelected,
|
onMovieSelected = onMovieSelected,
|
||||||
onSeriesSelected = onSeriesSelected,
|
onSeriesSelected = onSeriesSelected,
|
||||||
onEpisodeSelected = onEpisodeSelected
|
onEpisodeSelected = onEpisodeSelected
|
||||||
@@ -324,27 +242,150 @@ fun TvSectionHeader(
|
|||||||
onActionClick: () -> Unit = {}
|
onActionClick: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = TvHomeSectionsHorizontalPadding, vertical = 14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
color = scheme.onBackground,
|
color = scheme.onBackground,
|
||||||
fontSize = 20.sp,
|
fontSize = 22.sp,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
if (action != null) {
|
if (action != null) {
|
||||||
Text(
|
Box(
|
||||||
text = action,
|
modifier = Modifier
|
||||||
color = scheme.primary,
|
.clip(TvHomeSectionsPillShape)
|
||||||
fontSize = 14.sp,
|
.background(scheme.surfaceContainerHigh.copy(alpha = 0.82f))
|
||||||
fontWeight = FontWeight.SemiBold,
|
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.6f), TvHomeSectionsPillShape)
|
||||||
modifier = Modifier.clickable { onActionClick() })
|
.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
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Tab
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.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.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
|
@Composable
|
||||||
fun TvHomeTopBar(
|
fun TvHomeTopBar(
|
||||||
tabs: List<TvHomeTabItem>,
|
tabs: List<TvHomeTabItem>,
|
||||||
@@ -23,29 +48,92 @@ fun TvHomeTopBar(
|
|||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, tabs.lastIndex.coerceAtLeast(0))
|
val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, tabs.lastIndex.coerceAtLeast(0))
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
PrimaryScrollableTabRow(
|
LaunchedEffect(safeSelectedTabIndex) {
|
||||||
selectedTabIndex = safeSelectedTabIndex,
|
if (tabs.isNotEmpty()) {
|
||||||
containerColor = scheme.surface,
|
listState.animateScrollToItem(safeSelectedTabIndex)
|
||||||
contentColor = scheme.onSurface,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ->
|
itemsIndexed(items = tabs) { index, tab ->
|
||||||
Tab(
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
selected = index == safeSelectedTabIndex,
|
val isSelected = index == safeSelectedTabIndex
|
||||||
onClick = { onTabSelected(index, tab) },
|
val containerColor by animateColorAsState(
|
||||||
text = {
|
targetValue = when {
|
||||||
Row(
|
isSelected -> scheme.surfaceContainerHigh
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
isFocused -> scheme.surfaceContainerHigh.copy(alpha = 0.92f)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
else -> scheme.surfaceContainer.copy(alpha = 0.72f)
|
||||||
) {
|
},
|
||||||
Icon(
|
label = "tv-home-tab-container"
|
||||||
imageVector = tab.icon,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
Text(text = tab.label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,20 +13,14 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import hu.bbara.purefin.app.home.ui.HomeContent
|
import hu.bbara.purefin.app.home.ui.HomeContent
|
||||||
import hu.bbara.purefin.app.home.ui.HomeTopBar
|
import hu.bbara.purefin.app.home.ui.HomeTopBar
|
||||||
import hu.bbara.purefin.app.home.ui.homePreviewContinueWatching
|
|
||||||
import hu.bbara.purefin.app.home.ui.homePreviewLibraries
|
|
||||||
import hu.bbara.purefin.app.home.ui.homePreviewLibraryContent
|
|
||||||
import hu.bbara.purefin.app.home.ui.homePreviewNextUp
|
|
||||||
import hu.bbara.purefin.app.home.ui.search.HomeSearchOverlay
|
import hu.bbara.purefin.app.home.ui.search.HomeSearchOverlay
|
||||||
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
|
||||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||||
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
import hu.bbara.purefin.feature.shared.home.NextUpItem
|
||||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||||
import hu.bbara.purefin.feature.shared.home.SuggestedItem
|
import hu.bbara.purefin.feature.shared.home.SuggestedItem
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -114,29 +108,4 @@ fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(name = "Home Screen", showBackground = true, widthDp = 412, heightDp = 915)
|
|
||||||
@Composable
|
|
||||||
private fun HomeScreenPreview() {
|
|
||||||
AppTheme(darkTheme = true) {
|
|
||||||
HomeScreen(
|
|
||||||
libraries = homePreviewLibraries(),
|
|
||||||
libraryContent = homePreviewLibraryContent(),
|
|
||||||
suggestions = emptyList(),
|
|
||||||
continueWatching = homePreviewContinueWatching(),
|
|
||||||
nextUp = homePreviewNextUp(),
|
|
||||||
isRefreshing = false,
|
|
||||||
onRefresh = {},
|
|
||||||
onMovieSelected = {},
|
|
||||||
onSeriesSelected = {},
|
|
||||||
onEpisodeSelected = { _, _, _ -> },
|
|
||||||
onLibrarySelected = {},
|
|
||||||
onProfileClick = {},
|
|
||||||
onSettingsClick = {},
|
|
||||||
onLogoutClick = {},
|
|
||||||
selectedTab = 0,
|
|
||||||
onTabSelected = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,6 +39,13 @@ class AppViewModel @Inject constructor(
|
|||||||
private val _isRefreshing = MutableStateFlow(false)
|
private val _isRefreshing = MutableStateFlow(false)
|
||||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||||
|
|
||||||
|
val serverUrl: StateFlow<String> = userSessionRepository.serverUrl
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(5_000),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
val libraries = appContentRepository.libraries.map { libraries ->
|
val libraries = appContentRepository.libraries.map { libraries ->
|
||||||
libraries.map {
|
libraries.map {
|
||||||
LibraryItem(
|
LibraryItem(
|
||||||
|
|||||||
@@ -88,26 +88,51 @@ data class SuggestedMovie (
|
|||||||
)
|
)
|
||||||
) : SuggestedItem
|
) : SuggestedItem
|
||||||
|
|
||||||
|
sealed interface FocusableItem {
|
||||||
|
val imageUrl: String
|
||||||
|
val primaryText: String
|
||||||
|
val secondaryText: String
|
||||||
|
val description: String
|
||||||
|
val id: UUID
|
||||||
|
val type: BaseItemKind
|
||||||
|
}
|
||||||
|
|
||||||
data class ContinueWatchingItem(
|
data class ContinueWatchingItem(
|
||||||
val type: BaseItemKind,
|
override val type: BaseItemKind,
|
||||||
val movie: Movie? = null,
|
val movie: Movie? = null,
|
||||||
val episode: Episode? = null
|
val episode: Episode? = null,
|
||||||
) {
|
) : FocusableItem {
|
||||||
val id: UUID = when (type) {
|
override val id: UUID = when (type) {
|
||||||
BaseItemKind.MOVIE -> movie!!.id
|
BaseItemKind.MOVIE -> movie!!.id
|
||||||
BaseItemKind.EPISODE -> episode!!.id
|
BaseItemKind.EPISODE -> episode!!.id
|
||||||
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||||
}
|
}
|
||||||
val primaryText: String = when (type) {
|
override val primaryText: String = when (type) {
|
||||||
BaseItemKind.MOVIE -> movie!!.title
|
BaseItemKind.MOVIE -> movie!!.title
|
||||||
BaseItemKind.EPISODE -> episode!!.title
|
BaseItemKind.EPISODE -> episode!!.title
|
||||||
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||||
}
|
}
|
||||||
val secondaryText: String = when (type) {
|
override val secondaryText: String = when (type) {
|
||||||
BaseItemKind.MOVIE -> movie!!.year
|
BaseItemKind.MOVIE -> movie!!.year
|
||||||
BaseItemKind.EPISODE -> episode!!.releaseDate
|
BaseItemKind.EPISODE -> episode!!.releaseDate
|
||||||
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||||
}
|
}
|
||||||
|
override val imageUrl: String = when (type) {
|
||||||
|
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
|
||||||
|
prefixImageUrl = movie!!.imageUrlPrefix,
|
||||||
|
imageType = ImageType.PRIMARY
|
||||||
|
)
|
||||||
|
BaseItemKind.EPISODE -> JellyfinImageHelper.finishImageUrl(
|
||||||
|
prefixImageUrl = episode!!.imageUrlPrefix,
|
||||||
|
imageType = ImageType.PRIMARY
|
||||||
|
)
|
||||||
|
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||||
|
}
|
||||||
|
override val description: String = when (type) {
|
||||||
|
BaseItemKind.MOVIE -> movie!!.synopsis
|
||||||
|
BaseItemKind.EPISODE -> episode!!.synopsis
|
||||||
|
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||||
|
}
|
||||||
val progress: Double = when (type) {
|
val progress: Double = when (type) {
|
||||||
BaseItemKind.MOVIE -> movie!!.progress ?: 0.0
|
BaseItemKind.MOVIE -> movie!!.progress ?: 0.0
|
||||||
BaseItemKind.EPISODE -> episode!!.progress ?: 0.0
|
BaseItemKind.EPISODE -> episode!!.progress ?: 0.0
|
||||||
@@ -117,10 +142,19 @@ data class ContinueWatchingItem(
|
|||||||
|
|
||||||
data class NextUpItem(
|
data class NextUpItem(
|
||||||
val episode: Episode
|
val episode: Episode
|
||||||
) {
|
) : FocusableItem {
|
||||||
val id: UUID = episode.id
|
override val id: UUID = episode.id
|
||||||
val primaryText: String = episode.title
|
override val type: BaseItemKind
|
||||||
val secondaryText: String = episode.releaseDate
|
get() = BaseItemKind.EPISODE
|
||||||
|
override val imageUrl: String
|
||||||
|
get() = JellyfinImageHelper.finishImageUrl(
|
||||||
|
prefixImageUrl = episode.imageUrlPrefix,
|
||||||
|
imageType = ImageType.PRIMARY
|
||||||
|
)
|
||||||
|
override val primaryText: String = episode.title
|
||||||
|
override val secondaryText: String = episode.releaseDate
|
||||||
|
override val description: String
|
||||||
|
get() = episode.synopsis
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LibraryItem(
|
data class LibraryItem(
|
||||||
@@ -132,12 +166,12 @@ data class LibraryItem(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class PosterItem(
|
data class PosterItem(
|
||||||
val type: BaseItemKind,
|
override val type: BaseItemKind,
|
||||||
val movie: Movie? = null,
|
val movie: Movie? = null,
|
||||||
val series: Series? = null,
|
val series: Series? = null,
|
||||||
val episode: Episode? = null
|
val episode: Episode? = null
|
||||||
) {
|
) : FocusableItem {
|
||||||
val id: UUID = when (type) {
|
override val id: UUID = when (type) {
|
||||||
BaseItemKind.MOVIE -> movie!!.id
|
BaseItemKind.MOVIE -> movie!!.id
|
||||||
BaseItemKind.EPISODE -> episode!!.id
|
BaseItemKind.EPISODE -> episode!!.id
|
||||||
BaseItemKind.SERIES -> series!!.id
|
BaseItemKind.SERIES -> series!!.id
|
||||||
@@ -149,7 +183,7 @@ data class PosterItem(
|
|||||||
BaseItemKind.SERIES -> series!!.name
|
BaseItemKind.SERIES -> series!!.name
|
||||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||||
}
|
}
|
||||||
val imageUrl: String = when (type) {
|
override val imageUrl: String = when (type) {
|
||||||
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
|
BaseItemKind.MOVIE -> JellyfinImageHelper.finishImageUrl(
|
||||||
prefixImageUrl = movie!!.imageUrlPrefix,
|
prefixImageUrl = movie!!.imageUrlPrefix,
|
||||||
imageType = ImageType.PRIMARY
|
imageType = ImageType.PRIMARY
|
||||||
@@ -164,6 +198,28 @@ data class PosterItem(
|
|||||||
)
|
)
|
||||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||||
}
|
}
|
||||||
|
override val primaryText: String
|
||||||
|
get() = when (type) {
|
||||||
|
BaseItemKind.MOVIE -> movie!!.title
|
||||||
|
BaseItemKind.EPISODE -> episode!!.title
|
||||||
|
BaseItemKind.SERIES -> series!!.name
|
||||||
|
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||||
|
}
|
||||||
|
override val secondaryText: String
|
||||||
|
get() = when (type) {
|
||||||
|
BaseItemKind.MOVIE -> movie!!.year
|
||||||
|
BaseItemKind.EPISODE -> episode!!.releaseDate
|
||||||
|
BaseItemKind.SERIES -> series!!.year
|
||||||
|
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||||
|
}
|
||||||
|
override val description: String
|
||||||
|
get() = when (type) {
|
||||||
|
BaseItemKind.MOVIE -> movie!!.synopsis
|
||||||
|
BaseItemKind.EPISODE -> episode!!.synopsis
|
||||||
|
BaseItemKind.SERIES -> series!!.synopsis
|
||||||
|
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||||
|
}
|
||||||
|
|
||||||
fun watched() = when (type) {
|
fun watched() = when (type) {
|
||||||
BaseItemKind.MOVIE -> movie!!.watched
|
BaseItemKind.MOVIE -> movie!!.watched
|
||||||
BaseItemKind.EPISODE -> episode!!.watched
|
BaseItemKind.EPISODE -> episode!!.watched
|
||||||
|
|||||||
Reference in New Issue
Block a user