Compare commits

...

2 Commits

Author SHA1 Message Date
69b3933175 feat: added TvHomeFocused section to the TvHomeScreen 2026-04-05 19:54:13 +02:00
0011420bf2 feat: add initial focus management and media metadata for TV home screen
Introduce a `FocusableItem` interface to unify media item properties (ID, type, text, and images) across `ContinueWatchingItem`, `NextUpItem`, and `PosterItem`.

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

View File

@@ -0,0 +1,230 @@
package hu.bbara.purefin.tv.home.ui
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.tv.home.TvHomeScreen
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Rule
import org.junit.Test
import java.util.UUID
@OptIn(ExperimentalTestApi::class)
class TvHomeContentTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun tvHomeContent_focusesFirstAvailableItemByDefault() {
composeRule.setContent {
AppTheme {
TvHomeContent(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
onMediaFocused = {},
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
}
@Test
fun tvHomeScreen_showsHeroForInitiallyFocusedItem() {
composeRule.setContent {
AppTheme {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeInitialFocusTag).assertIsFocused()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
composeRule.onNodeWithTag(TvHomeHeroProgressLabelTag).assertTextEquals("42%")
}
@Test
fun tvHomeScreen_updatesHeroWhenFocusMovesWithinRow() {
composeRule.setContent {
AppTheme {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatchingRow(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Blade Runner 2049")
composeRule.onNodeWithTag(TvHomeInitialFocusTag)
.assertIsFocused()
.performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Mad Max: Fury Road")
}
@Test
fun tvHomeScreen_compactHeroKeepsFirstRowVisible() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertIsDisplayed()
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
composeRule.onNodeWithTag(TvHomeInitialFocusTag)
.assertIsDisplayed()
.assertIsFocused()
}
private fun sampleContinueWatching(): List<ContinueWatchingItem> {
return listOf(
ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(
id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049",
progress = 42.0
)
)
)
}
private fun sampleContinueWatchingRow(): List<ContinueWatchingItem> {
return listOf(
ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(
id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049",
progress = 42.0
)
),
ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(
id = "55555555-5555-5555-5555-555555555555",
title = "Mad Max: Fury Road",
progress = 8.0
)
)
)
}
private fun sampleLibraries(): List<LibraryItem> {
return listOf(
LibraryItem(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
name = "Movies",
type = CollectionType.MOVIES,
posterUrl = "",
isEmpty = false
)
)
}
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333")
return mapOf(
libraryId to listOf(
PosterItem(
type = BaseItemKind.MOVIE,
movie = sampleMovie(
id = "44444444-4444-4444-4444-444444444444",
title = "Arrival",
progress = null,
libraryId = libraryId
)
)
)
)
}
private fun sampleMovie(
id: String,
title: String,
progress: Double?,
libraryId: UUID = UUID.fromString("22222222-2222-2222-2222-222222222222")
): Movie {
return Movie(
id = UUID.fromString(id),
libraryId = libraryId,
title = title,
progress = progress,
watched = false,
year = "2017",
rating = "16+",
runtime = "164m",
format = "4K",
synopsis = "Officer K uncovers a secret that sends him searching for Rick Deckard.",
imageUrlPrefix = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
audioTrack = "ENG 5.1",
subtitles = "ENG",
cast = emptyList()
)
}
}

View File

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

View File

@@ -26,11 +26,13 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.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
)
}
}
} }
} }

View File

@@ -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
)
)
)
)
}

View File

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

View File

@@ -2,6 +2,7 @@ package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.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)
) )
} }
} }

View File

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

View File

@@ -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()
)
}
}
} }

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package hu.bbara.purefin.tv.home.ui 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))
} }
} }
} }

View File

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

View File

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

View File

@@ -28,10 +28,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.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
)
}
} }
} }
} }

View File

@@ -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
)
}
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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