Compare commits

...

6 Commits

23 changed files with 779 additions and 623 deletions

View File

@@ -13,7 +13,6 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.ui.theme.AppTheme
@@ -34,7 +33,6 @@ class EpisodeScreenContentTest {
EpisodeScreenContent(
episode = sampleEpisode(progress = 63.0),
seriesTitle = "Severance",
topBarShortcut = episodeTopBarShortcut(Route.Home, onSeriesClick = {}),
onBack = {},
onPlay = {}
)
@@ -60,16 +58,12 @@ class EpisodeScreenContentTest {
}
@Test
fun episodeScreenContent_hidesShortcut_whenNoShortcutIsProvided() {
fun episodeScreenContent_hidesSeriesShortcut_whenShortcutUiIsUnavailable() {
composeRule.setContent {
AppTheme {
EpisodeScreenContent(
episode = sampleEpisode(progress = null),
seriesTitle = "Severance",
topBarShortcut = episodeTopBarShortcut(
previousRoute = Route.PlayerRoute(mediaId = "episode-4"),
onSeriesClick = {}
),
onBack = {},
onPlay = {}
)

View File

@@ -1,61 +0,0 @@
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

@@ -0,0 +1,99 @@
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.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.Home
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class TvNavigationDrawerTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun tvDrawerHeader_onlyShowsTitleWhenExpanded() {
var expanded by mutableStateOf(false)
composeRule.setContent {
AppTheme {
TvDrawerHeader(expanded = expanded)
}
}
composeRule.onAllNodesWithTag(TvDrawerTitleTag).assertCountEquals(0)
composeRule.runOnUiThread {
expanded = true
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvDrawerTitleTag).assertIsDisplayed()
}
@Test
fun tvNavigationDrawer_updatesSelectedDestination() {
var selectedDestination by mutableStateOf(TvDrawerDestination.HOME)
composeRule.setContent {
AppTheme {
TvNavigationDrawer(
destinations = listOf(
TvDrawerDestinationItem(
destination = TvDrawerDestination.HOME,
label = "Home",
icon = Icons.Outlined.Home
),
TvDrawerDestinationItem(
destination = TvDrawerDestination.LIBRARIES,
label = "Libraries",
icon = Icons.Outlined.Collections
)
),
selectedDestination = selectedDestination,
onDestinationSelected = { destination ->
selectedDestination = destination
}
) {
Box(modifier = Modifier.size(320.dp))
}
}
}
composeRule.onNodeWithTag("${TvDrawerItemTagPrefix}0").assertIsSelected()
composeRule.onNodeWithTag("${TvDrawerItemTagPrefix}1")
.assertIsNotSelected()
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput {
pressKey(Key.DirectionCenter)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag("${TvDrawerItemTagPrefix}1").assertIsSelected()
composeRule.onNodeWithTag("${TvDrawerItemTagPrefix}0").assertIsNotSelected()
}
}

View File

@@ -0,0 +1,56 @@
package hu.bbara.purefin.tv.library.ui
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.UUID
class TvLibrariesOverviewScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun tvLibrariesOverviewScreen_opensSelectedLibrary() {
val libraries = listOf(
LibraryItem(
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
name = "Movies",
type = CollectionType.MOVIES,
posterUrl = "",
isEmpty = false
),
LibraryItem(
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
name = "Shows",
type = CollectionType.TVSHOWS,
posterUrl = "",
isEmpty = false
)
)
var openedLibrary: LibraryItem? = null
composeRule.setContent {
AppTheme {
TvLibrariesOverviewScreen(
libraries = libraries,
onLibrarySelected = { library ->
openedLibrary = library
}
)
}
}
composeRule.onNodeWithTag("${TvLibrariesOverviewItemTagPrefix}1").performClick()
composeRule.waitForIdle()
assertEquals(libraries[1], openedLibrary)
}
}

View File

@@ -21,50 +21,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar
import hu.bbara.purefin.common.ui.components.MediaDetailsTopBarShortcut
import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.model.Episode
internal const val EpisodePlayButtonTag = "episode-play-button"
internal sealed interface EpisodeTopBarShortcut {
val label: String
val onClick: () -> Unit
data class Series(override val onClick: () -> Unit) : EpisodeTopBarShortcut {
override val label: String = "Series"
}
}
internal fun episodeTopBarShortcut(
previousRoute: Route?,
onSeriesClick: () -> Unit
): EpisodeTopBarShortcut? {
return when (previousRoute) {
Route.Home -> EpisodeTopBarShortcut.Series(onClick = onSeriesClick)
else -> null
}
}
@Composable
internal fun EpisodeTopBar(
onBack: () -> Unit,
shortcut: EpisodeTopBarShortcut? = null,
modifier: Modifier = Modifier,
backFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null
) {
MediaDetailsTopBar(
onBack = onBack,
shortcut = shortcut?.let { MediaDetailsTopBarShortcut(label = it.label, onClick = it.onClick) },
modifier = modifier,
backFocusRequester = backFocusRequester,
downFocusRequester = downFocusRequester
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun EpisodeHeroSection(

View File

@@ -8,7 +8,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
@@ -58,12 +57,6 @@ fun EpisodeScreen(
EpisodeScreenContent(
episode = selectedEpisode,
seriesTitle = seriesTitle.value,
topBarShortcut = remember(previousRoute, viewModel) {
episodeTopBarShortcut(
previousRoute = previousRoute,
onSeriesClick = viewModel::onSeriesClick
)
},
onBack = viewModel::onBack,
onPlay = remember(selectedEpisode.id, navigationManager) {
{
@@ -80,31 +73,22 @@ fun EpisodeScreen(
internal fun EpisodeScreenContent(
episode: Episode,
seriesTitle: String?,
topBarShortcut: EpisodeTopBarShortcut?,
onBack: () -> Unit,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
val backFocusRequester = remember { FocusRequester() }
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(episode.id) {
backFocusRequester.requestFocus()
playFocusRequester.requestFocus()
}
TvMediaDetailScaffold(
heroImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
artworkImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 280.dp,
artworkAspectRatio = 16f / 9f,
resetScrollKey = episode.id,
modifier = modifier,
topBar = {
EpisodeTopBar(
onBack = onBack,
shortcut = topBarShortcut,
backFocusRequester = backFocusRequester,
downFocusRequester = playFocusRequester,
modifier = Modifier.align(Alignment.TopStart)
)
},
heroContent = {
EpisodeHeroSection(
episode = episode,

View File

@@ -21,27 +21,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar
import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.core.model.Movie
internal const val MoviePlayButtonTag = "movie-play-button"
@Composable
internal fun MovieTopBar(
onBack: () -> Unit,
modifier: Modifier = Modifier,
backFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null
) {
MediaDetailsTopBar(
onBack = onBack,
modifier = modifier,
backFocusRequester = backFocusRequester,
downFocusRequester = downFocusRequester
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun MovieHeroSection(

View File

@@ -8,7 +8,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
@@ -54,25 +53,18 @@ internal fun MovieScreenContent(
onPlay: () -> Unit,
modifier: Modifier = Modifier,
) {
val backFocusRequester = remember { FocusRequester() }
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(movie.id) {
backFocusRequester.requestFocus()
playFocusRequester.requestFocus()
}
TvMediaDetailScaffold(
heroImageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY),
artworkImageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 200.dp,
artworkAspectRatio = 2f / 3f,
resetScrollKey = movie.id,
modifier = modifier,
topBar = {
MovieTopBar(
onBack = onBack,
backFocusRequester = backFocusRequester,
downFocusRequester = playFocusRequester,
modifier = Modifier.align(Alignment.TopStart)
)
},
heroContent = {
MovieHeroSection(
movie = movie,

View File

@@ -55,7 +55,6 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar
import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
@@ -71,21 +70,6 @@ import org.jellyfin.sdk.model.api.ImageType
internal const val SeriesPlayButtonTag = "series-play-button"
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
@Composable
internal fun SeriesTopBar(
onBack: () -> Unit,
modifier: Modifier = Modifier,
backFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null
) {
MediaDetailsTopBar(
onBack = onBack,
modifier = modifier,
backFocusRequester = backFocusRequester,
downFocusRequester = downFocusRequester
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SeriesMetaChips(series: Series) {

View File

@@ -9,7 +9,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
@@ -71,26 +70,23 @@ internal fun SeriesScreenContent(
season.episodes.firstOrNull { !it.watched }
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull()
}
val backFocusRequester = remember { FocusRequester() }
val playFocusRequester = remember { FocusRequester() }
val firstContentFocusRequester = remember { FocusRequester() }
LaunchedEffect(series.id) {
backFocusRequester.requestFocus()
LaunchedEffect(series.id, nextUpEpisode?.id) {
if (nextUpEpisode != null) {
playFocusRequester.requestFocus()
} else {
firstContentFocusRequester.requestFocus()
}
}
TvMediaDetailScaffold(
heroImageUrl = JellyfinImageHelper.finishImageUrl(series.imageUrlPrefix, ImageType.PRIMARY),
artworkImageUrl = JellyfinImageHelper.finishImageUrl(series.imageUrlPrefix, ImageType.PRIMARY),
artworkWidth = 200.dp,
artworkAspectRatio = 2f / 3f,
resetScrollKey = series.id,
modifier = modifier,
topBar = {
SeriesTopBar(
onBack = onBack,
backFocusRequester = backFocusRequester,
downFocusRequester = nextUpEpisode?.let { playFocusRequester } ?: firstContentFocusRequester,
modifier = Modifier.align(Alignment.TopStart)
)
},
heroContent = {
SeriesHeroSection(
series = series,

View File

@@ -2,14 +2,18 @@ package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -17,21 +21,36 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
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.foundation.shape.RoundedCornerShape
import hu.bbara.purefin.common.ui.MediaSynopsis
internal val MediaDetailHorizontalPadding = 48.dp
private val MediaDetailHeaderTopPadding = 104.dp
private val MediaDetailHeaderBottomPadding = 36.dp
private val MediaDetailCornerArtworkTopPadding = 40.dp
private val MediaDetailCornerArtworkShape = RoundedCornerShape(24.dp)
private val MediaDetailMinimumContentWidth = 280.dp
private val MediaDetailContentArtworkGap = 32.dp
@Composable
internal fun TvMediaDetailScaffold(
heroImageUrl: String,
artworkImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
resetScrollKey: Any,
modifier: Modifier = Modifier,
heroHeightFraction: Float = 0.48f,
topBar: @Composable BoxScope.() -> Unit,
headerHeightFraction: Float = 0.48f,
heroContent: @Composable ColumnScope.() -> Unit,
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
) {
@@ -53,25 +72,121 @@ internal fun TvMediaDetailScaffold(
modifier = Modifier.fillMaxSize()
) {
item {
Box(modifier = Modifier.fillMaxWidth()) {
MediaHero(
imageUrl = heroImageUrl,
backgroundColor = scheme.background,
heightFraction = heroHeightFraction,
modifier = Modifier.fillMaxWidth()
TvMediaDetailHeader(
artworkImageUrl = artworkImageUrl,
artworkWidth = artworkWidth,
artworkAspectRatio = artworkAspectRatio,
headerHeightFraction = headerHeightFraction,
heroContent = heroContent
)
}
bodyContent(contentPadding)
}
}
}
@Composable
private fun TvMediaDetailHeader(
artworkImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
headerHeightFraction: Float,
heroContent: @Composable ColumnScope.() -> Unit
) {
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val headerHeight = screenHeight * headerHeightFraction
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = headerHeight)
) {
val contentMaxWidth = (
maxWidth -
(MediaDetailHorizontalPadding * 2) -
artworkWidth -
MediaDetailContentArtworkGap
).coerceAtLeast(MediaDetailMinimumContentWidth)
Box(modifier = Modifier.fillMaxWidth()) {
TvMediaDetailCornerArtwork(
artworkImageUrl = artworkImageUrl,
artworkWidth = artworkWidth,
artworkAspectRatio = artworkAspectRatio,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(
top = MediaDetailCornerArtworkTopPadding,
end = MediaDetailHorizontalPadding
)
)
MediaHeroScrimOverlay()
Column(
modifier = contentPadding
.padding(top = 104.dp, bottom = 36.dp)
modifier = Modifier
.align(Alignment.TopStart)
.padding(
start = MediaDetailHorizontalPadding,
top = MediaDetailHeaderTopPadding,
end = MediaDetailHorizontalPadding,
bottom = MediaDetailHeaderBottomPadding
)
.widthIn(max = contentMaxWidth)
) {
heroContent()
}
}
}
bodyContent(contentPadding)
}
topBar()
@Composable
private fun TvMediaDetailCornerArtwork(
artworkImageUrl: String,
artworkWidth: Dp,
artworkAspectRatio: Float,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.width(artworkWidth)
.aspectRatio(artworkAspectRatio)
.clip(MediaDetailCornerArtworkShape)
.background(scheme.surfaceVariant.copy(alpha = 0.28f))
) {
PurefinAsyncImage(
model = artworkImageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = 0.52f },
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.08f),
0.55f to scheme.background.copy(alpha = 0.2f),
1.0f to scheme.background.copy(alpha = 0.56f)
)
)
)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0.06f),
0.65f to scheme.background.copy(alpha = 0.22f),
1.0f to scheme.background.copy(alpha = 0.74f)
)
)
)
)
}
}

View File

@@ -1,50 +0,0 @@
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.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
@Composable
fun MediaHero(
imageUrl: String,
backgroundColor: Color,
heightFraction: Float = 0.4f,
modifier: Modifier = Modifier,
) {
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
val heroHeight = screenHeight * heightFraction
Box(
modifier = modifier
.height(heroHeight)
.background(backgroundColor)
) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
backgroundColor.copy(alpha = 0.5f),
backgroundColor
)
)
)
)
}
}

View File

@@ -1,42 +0,0 @@
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

@@ -1,9 +1,11 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -43,18 +45,30 @@ fun MediaResumeButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val primaryColor = MaterialTheme.colorScheme.primary
val onPrimaryColor = MaterialTheme.colorScheme.onPrimary
val scheme = MaterialTheme.colorScheme
val primaryColor = scheme.primary
val onPrimaryColor = scheme.onPrimary
val focusShape = RoundedCornerShape(50)
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.05f else 1.0f, label = "scale")
val scale by animateFloatAsState(targetValue = if (isFocused) 1.08f else 1.0f, label = "scale")
val focusBorderColor by animateColorAsState(
targetValue = if (isFocused) scheme.onBackground else Color.Transparent,
label = "focus-border"
)
val focusHaloColor by animateColorAsState(
targetValue = if (isFocused) scheme.primary.copy(alpha = 0.22f) else Color.Transparent,
label = "focus-halo"
)
BoxWithConstraints(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.height(52.dp)
.border(2.5.dp, if (isFocused) onPrimaryColor else Color.Transparent, RoundedCornerShape(50))
.clip(RoundedCornerShape(50))
.onFocusChanged { isFocused = it.isFocused }
.background(focusHaloColor, focusShape)
.border(3.dp, focusBorderColor, focusShape)
.clip(focusShape)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.focusable()
.clickable(onClick = onClick)
) {
// Bottom layer: inverted colors (visible for the remaining %)
@@ -87,6 +101,14 @@ fun MediaResumeButton(
) {
ButtonContent(text = text, color = onPrimaryColor)
}
if (isFocused) {
Box(
modifier = Modifier
.fillMaxSize()
.border(2.dp, scheme.primary.copy(alpha = 0.95f), focusShape)
)
}
}
}

View File

@@ -1,38 +1,31 @@
package hu.bbara.purefin.tv
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Tv
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import hu.bbara.purefin.feature.shared.home.AppViewModel
import hu.bbara.purefin.feature.shared.library.LibraryViewModel
import hu.bbara.purefin.tv.home.TvHomeScreen
import hu.bbara.purefin.tv.home.ui.TvHomeTabDestination
import hu.bbara.purefin.tv.home.ui.TvHomeTabItem
import hu.bbara.purefin.tv.home.ui.TvHomeTopBar
import hu.bbara.purefin.tv.library.ui.TvLibraryContent
import hu.bbara.purefin.tv.home.ui.TvDrawerDestination
import hu.bbara.purefin.tv.home.ui.TvDrawerDestinationItem
import hu.bbara.purefin.tv.home.ui.TvNavigationDrawer
import hu.bbara.purefin.tv.library.ui.TvLibrariesOverviewScreen
import org.jellyfin.sdk.model.api.CollectionType
@Composable
fun TvAppScreen(
viewModel: AppViewModel = hiltViewModel(),
libraryViewModel: LibraryViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val serverUrl by viewModel.serverUrl.collectAsState()
@@ -40,46 +33,28 @@ fun TvAppScreen(
val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.collectAsState()
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState()
val selectedLibraryItems by libraryViewModel.contents.collectAsState()
var selectedTabIndex by remember { mutableIntStateOf(1) }
var selectedDestination by rememberSaveable { androidx.compose.runtime.mutableStateOf(TvDrawerDestination.HOME) }
val tabs = remember(libraries) {
buildList {
add(
TvHomeTabItem(
destination = TvHomeTabDestination.SETTINGS,
label = "Settings",
icon = Icons.Outlined.Settings,
)
)
add(
TvHomeTabItem(
destination = TvHomeTabDestination.SEARCH,
label = "Search",
icon = Icons.Outlined.Search,
)
)
add(
TvHomeTabItem(
destination = TvHomeTabDestination.HOME,
val destinations = remember(libraries, selectedDestination) {
listOf(
TvDrawerDestinationItem(
destination = TvDrawerDestination.HOME,
label = "Home",
icon = Icons.Outlined.Home,
)
)
addAll(libraries.map {
TvHomeTabItem(
destination = TvHomeTabDestination.LIBRARY,
label = it.name,
icon = when (it.type) {
CollectionType.MOVIES -> Icons.Outlined.Movie
CollectionType.TVSHOWS -> Icons.Outlined.Tv
selected = selectedDestination == TvDrawerDestination.HOME
),
TvDrawerDestinationItem(
destination = TvDrawerDestination.LIBRARIES,
label = "Libraries",
icon = when {
libraries.any { it.type == CollectionType.MOVIES } -> Icons.Outlined.Movie
libraries.any { it.type == CollectionType.TVSHOWS } -> Icons.Outlined.Tv
else -> Icons.Outlined.Collections
},
libraryId = it.id
selected = selectedDestination == TvDrawerDestination.LIBRARIES
)
)
})
}
}
LifecycleResumeEffect(Unit) {
@@ -87,41 +62,16 @@ fun TvAppScreen(
onPauseOrDispose { }
}
val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, (tabs.size - 1).coerceAtLeast(0))
val selectedTab = tabs.getOrNull(safeSelectedTabIndex)
LaunchedEffect(selectedTab?.destination, selectedTab?.libraryId) {
if (selectedTab?.destination == TvHomeTabDestination.LIBRARY) {
val libraryId = selectedTab.libraryId ?: return@LaunchedEffect
libraryViewModel.selectLibrary(libraryId)
}
}
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
TvHomeTopBar(
tabs = tabs,
selectedTabIndex = safeSelectedTabIndex,
onTabSelected = { index, _ ->
selectedTabIndex = index
}
)
}
) { innerPadding ->
when (selectedTab?.destination) {
TvHomeTabDestination.SETTINGS,
TvHomeTabDestination.LIBRARY -> {
TvLibraryContent(
libraryItems = selectedLibraryItems,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
modifier = Modifier.padding(innerPadding)
)
}
TvHomeTabDestination.SEARCH,
TvHomeTabDestination.HOME,
null -> {
TvNavigationDrawer(
destinations = destinations,
selectedDestination = selectedDestination,
onDestinationSelected = { destination ->
selectedDestination = destination
},
modifier = modifier.fillMaxSize()
) {
when (selectedDestination) {
TvDrawerDestination.HOME -> {
TvHomeScreen(
libraries = libraries,
libraryContent = latestLibraryContent,
@@ -131,7 +81,16 @@ fun TvAppScreen(
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected,
modifier = Modifier.padding(innerPadding)
modifier = Modifier.fillMaxSize()
)
}
TvDrawerDestination.LIBRARIES -> {
TvLibrariesOverviewScreen(
libraries = libraries,
onLibrarySelected = { library ->
viewModel.onLibrarySelected(library.id, library.name)
},
modifier = Modifier.fillMaxSize()
)
}
}

View File

@@ -31,7 +31,6 @@ 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
@@ -40,7 +39,6 @@ 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(
@@ -53,7 +51,6 @@ internal fun TvFocusedItemHero(
modifier = modifier
.fillMaxWidth()
.height(height)
.clip(TvHomeHeroShape)
.background(scheme.background)
) {
Crossfade(
@@ -68,17 +65,16 @@ internal fun TvFocusedItemHero(
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)
colorStops = arrayOf(
0.0f to scheme.background,
0.28f to scheme.background.copy(alpha = 0.88f),
0.62f to scheme.background.copy(alpha = 0.42f),
1.0f to scheme.background.copy(alpha = 0.06f)
)
)
)
@@ -88,10 +84,10 @@ internal fun TvFocusedItemHero(
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
scheme.background.copy(alpha = 0f),
scheme.background.copy(alpha = 0.22f),
scheme.background.copy(alpha = 0.92f)
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0f),
0.56f to scheme.background.copy(alpha = 0.1f),
1.0f to scheme.background
)
)
)

View File

@@ -122,7 +122,6 @@ fun TvHomeContent(
TvLibraryPosterSection(
title = item.name,
items = libraryContent[item.id] ?: emptyList(),
action = "See All",
onFocusedItem = onMediaFocused,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,

View File

@@ -1,24 +0,0 @@
package hu.bbara.purefin.tv.home.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Tv
import org.jellyfin.sdk.model.UUID
object TvHomeMockData {
val user = TvHomeUser(name = "Alex User", plan = "Premium Account")
val primaryNavItems = listOf(
TvHomeNavItem(id = UUID.randomUUID(), label = "Home", icon = Icons.Outlined.Home, selected = true),
TvHomeNavItem(id = UUID.randomUUID(), label = "Movies", icon = Icons.Outlined.Movie),
TvHomeNavItem(id = UUID.randomUUID(), label = "TV Shows", icon = Icons.Outlined.Tv),
TvHomeNavItem(id = UUID.randomUUID(), label = "Search", icon = Icons.Outlined.Search)
)
val secondaryNavItems = listOf(
TvHomeNavItem(id = UUID.randomUUID(), label = "Settings", icon = Icons.Outlined.Settings)
)
}

View File

@@ -1,30 +1,15 @@
package hu.bbara.purefin.tv.home.ui
import androidx.compose.ui.graphics.vector.ImageVector
import org.jellyfin.sdk.model.UUID
data class TvHomeNavItem(
val id: UUID,
enum class TvDrawerDestination {
HOME,
LIBRARIES
}
data class TvDrawerDestinationItem(
val destination: TvDrawerDestination,
val label: String,
val icon: ImageVector,
val selected: Boolean = false
)
enum class TvHomeTabDestination {
SEARCH,
HOME,
LIBRARY,
SETTINGS
}
data class TvHomeTabItem(
val destination: TvHomeTabDestination,
val label: String,
val icon: ImageVector,
val libraryId: UUID? = null
)
data class TvHomeUser(
val name: String,
val plan: String
)

View File

@@ -53,7 +53,6 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
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
@@ -72,7 +71,6 @@ fun TvContinueWatchingSection(
if (items.isEmpty()) return
TvSectionHeader(
title = "Continue Watching",
action = null
)
LazyRow(
modifier = modifier.fillMaxWidth(),
@@ -97,7 +95,6 @@ fun TvContinueWatchingSection(
else -> null
},
badgeText = if (progressFraction > 0f) "Resume" else null,
progress = progressFraction,
imageModifier = Modifier
.then(
@@ -143,7 +140,6 @@ fun TvNextUpSection(
if (items.isEmpty()) return
TvSectionHeader(
title = "Next Up",
action = null
)
LazyRow(
modifier = modifier.fillMaxWidth(),
@@ -155,7 +151,6 @@ fun TvNextUpSection(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = item.imageUrl,
badgeText = "Next Up",
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
@@ -185,7 +180,6 @@ fun TvNextUpSection(
fun TvLibraryPosterSection(
title: String,
items: List<PosterItem>,
action: String?,
onFocusedItem: (FocusableItem) -> Unit = {},
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
@@ -196,7 +190,6 @@ fun TvLibraryPosterSection(
) {
TvSectionHeader(
title = title,
action = action
)
LazyRow(
modifier = modifier.fillMaxWidth(),
@@ -237,9 +230,7 @@ fun TvLibraryPosterSection(
@Composable
fun TvSectionHeader(
title: String,
action: String?,
modifier: Modifier = Modifier,
onActionClick: () -> Unit = {}
) {
val scheme = MaterialTheme.colorScheme
Row(
@@ -255,24 +246,6 @@ fun TvSectionHeader(
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold
)
if (action != null) {
Box(
modifier = Modifier
.clip(TvHomeSectionsPillShape)
.background(scheme.surfaceContainerHigh.copy(alpha = 0.82f))
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.6f), TvHomeSectionsPillShape)
.clickable { onActionClick() }
.padding(horizontal = 12.dp, vertical = 7.dp),
contentAlignment = Alignment.Center
) {
Text(
text = action,
color = scheme.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
@@ -283,7 +256,6 @@ private fun TvHomeLandscapeCard(
imageUrl: String?,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
badgeText: String? = null,
progress: Float? = null,
onFocusedItem: () -> Unit = {},
onClick: () -> Unit,
@@ -342,15 +314,6 @@ private fun TvHomeLandscapeCard(
)
)
)
badgeText?.let { badge ->
TvHomeMetaChip(
text = badge,
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp),
highlighted = isFocused
)
}
if (progress != null && progress > 0f) {
MediaProgressBar(
progress = progress,

View File

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

View File

@@ -0,0 +1,189 @@
package hu.bbara.purefin.tv.home.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.DrawerValue
import androidx.tv.material3.Icon
import androidx.tv.material3.NavigationDrawer
import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.Text
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
import androidx.tv.material3.darkColorScheme
import hu.bbara.purefin.tv.R
internal const val TvDrawerItemTagPrefix = "tv-drawer-item-"
internal const val TvDrawerTitleTag = "tv-drawer-title"
private val TvDrawerCollapsedWidth = 92.dp
private val TvDrawerExpandedWidth = 280.dp
@Composable
fun TvNavigationDrawer(
destinations: List<TvDrawerDestinationItem>,
selectedDestination: TvDrawerDestination,
onDestinationSelected: (TvDrawerDestination) -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
ProvideTvDrawerTheme {
NavigationDrawer(
modifier = modifier.fillMaxSize(),
drawerContent = {
TvNavigationDrawerRail(
drawerValue = if (hasFocus) DrawerValue.Open else DrawerValue.Closed,
destinations = destinations,
selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected
)
},
content = content
)
}
}
@Composable
private fun ProvideTvDrawerTheme(content: @Composable () -> Unit) {
val scheme = MaterialTheme.colorScheme
TvMaterialTheme(
colorScheme = darkColorScheme(
primary = scheme.primary,
onPrimary = scheme.onPrimary,
primaryContainer = scheme.primaryContainer,
onPrimaryContainer = scheme.onPrimaryContainer,
inversePrimary = scheme.inversePrimary,
secondary = scheme.secondary,
onSecondary = scheme.onSecondary,
secondaryContainer = scheme.secondaryContainer,
onSecondaryContainer = scheme.onSecondaryContainer,
tertiary = scheme.tertiary,
onTertiary = scheme.onTertiary,
tertiaryContainer = scheme.tertiaryContainer,
onTertiaryContainer = scheme.onTertiaryContainer,
background = scheme.background,
onBackground = scheme.onBackground,
surface = scheme.surface,
onSurface = scheme.onSurface,
surfaceVariant = scheme.surfaceVariant,
onSurfaceVariant = scheme.onSurfaceVariant,
surfaceTint = scheme.surfaceTint,
inverseSurface = scheme.inverseSurface,
inverseOnSurface = scheme.inverseOnSurface,
error = scheme.error,
onError = scheme.onError,
errorContainer = scheme.errorContainer,
onErrorContainer = scheme.onErrorContainer,
border = scheme.outline,
borderVariant = scheme.outlineVariant,
scrim = scheme.scrim
),
content = content
)
}
@Composable
fun TvDrawerHeader(
expanded: Boolean,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Image(
painter = painterResource(id = R.mipmap.purefin_logo_foreground),
contentDescription = null,
modifier = Modifier.size(40.dp)
)
AnimatedVisibility(visible = expanded) {
Text(
text = "Purefin",
modifier = Modifier.testTag(TvDrawerTitleTag)
)
}
}
}
@Composable
private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
drawerValue: DrawerValue,
destinations: List<TvDrawerDestinationItem>,
selectedDestination: TvDrawerDestination,
onDestinationSelected: (TvDrawerDestination) -> Unit,
) {
val expanded = drawerValue == DrawerValue.Open
val drawerWidth = animateDpAsState(
targetValue = if (expanded) TvDrawerExpandedWidth else TvDrawerCollapsedWidth,
label = "tv-drawer-width"
)
val scheme = androidx.tv.material3.MaterialTheme.colorScheme
Box(
modifier = Modifier
.width(drawerWidth.value)
.fillMaxHeight()
.background(scheme.surface.copy(alpha = 0.96f))
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 24.dp)
.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TvDrawerHeader(
expanded = expanded,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
)
destinations.forEachIndexed { index, destination ->
val isSelected = destination.destination == selectedDestination
NavigationDrawerItem(
selected = isSelected,
onClick = { onDestinationSelected(destination.destination) },
modifier = Modifier
.testTag("$TvDrawerItemTagPrefix$index")
.semantics { selected = isSelected },
leadingContent = {
Icon(
imageVector = destination.icon,
contentDescription = destination.label
)
}
) {
if (expanded) {
Text(
text = destination.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
}
}
}

View File

@@ -0,0 +1,194 @@
package hu.bbara.purefin.tv.library.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.outlined.Tv
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.home.LibraryItem
import org.jellyfin.sdk.model.api.CollectionType
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"
private val TvLibrariesOverviewCardShape = RoundedCornerShape(18.dp)
@Composable
fun TvLibrariesOverviewScreen(
libraries: List<LibraryItem>,
onLibrarySelected: (LibraryItem) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val firstItemFocusRequester = remember { FocusRequester() }
var initialFocusApplied by remember { mutableStateOf(false) }
LaunchedEffect(libraries.size, initialFocusApplied) {
if (!initialFocusApplied && libraries.isNotEmpty()) {
firstItemFocusRequester.requestFocus()
initialFocusApplied = true
}
}
Column(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
.padding(top = 24.dp)
) {
Text(
text = "Libraries",
style = MaterialTheme.typography.headlineMedium,
color = scheme.onBackground,
modifier = Modifier.padding(horizontal = 28.dp)
)
if (libraries.isEmpty()) {
Text(
text = "No libraries available",
style = MaterialTheme.typography.bodyLarge,
color = scheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp)
)
return
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 220.dp),
contentPadding = PaddingValues(horizontal = 28.dp, vertical = 24.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
modifier = Modifier.fillMaxSize()
) {
itemsIndexed(libraries, key = { _, item -> item.id }) { index, item ->
TvLibraryOverviewCard(
item = item,
onClick = { onLibrarySelected(item) },
modifier = Modifier
.then(
if (index == 0) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.testTag("$TvLibrariesOverviewItemTagPrefix$index")
)
}
}
}
}
@Composable
private fun TvLibraryOverviewCard(
item: LibraryItem,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) 1.05f else 1f,
label = "tv-library-overview-scale"
)
Column(
modifier = modifier
.fillMaxWidth()
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0.5f, 0f)
}
) {
Box(
modifier = Modifier
.aspectRatio(16f / 9f)
.clip(TvLibrariesOverviewCardShape)
.border(
width = if (isFocused) 2.dp else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.45f),
shape = TvLibrariesOverviewCardShape
)
.background(scheme.surfaceVariant)
.onFocusChanged { isFocused = it.isFocused }
.clickable(onClick = onClick)
) {
if (item.posterUrl.isNotBlank()) {
PurefinAsyncImage(
model = item.posterUrl,
contentDescription = item.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} else {
Icon(
imageVector = when (item.type) {
CollectionType.MOVIES -> Icons.Outlined.Movie
CollectionType.TVSHOWS -> Icons.Outlined.Tv
else -> Icons.Outlined.Collections
},
contentDescription = null,
tint = scheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.Center)
.size(48.dp)
)
}
}
Text(
text = item.name,
style = MaterialTheme.typography.titleMedium,
color = scheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 10.dp)
)
Text(
text = if (item.isEmpty) "Empty library" else "Open library",
style = MaterialTheme.typography.bodyMedium,
color = scheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp)
)
}
}