mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Compare commits
6 Commits
69b3933175
...
2937ee836b
| Author | SHA1 | Date | |
|---|---|---|---|
| 2937ee836b | |||
| 6dc3442089 | |||
| df67348f24 | |||
| bdef25d36a | |||
| cf296c3286 | |||
| 9161c23507 |
@@ -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 = {}
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
MediaHeroScrimOverlay()
|
||||
Column(
|
||||
modifier = contentPadding
|
||||
.padding(top = 104.dp, bottom = 36.dp)
|
||||
) {
|
||||
heroContent()
|
||||
}
|
||||
}
|
||||
TvMediaDetailHeader(
|
||||
artworkImageUrl = artworkImageUrl,
|
||||
artworkWidth = artworkWidth,
|
||||
artworkAspectRatio = artworkAspectRatio,
|
||||
headerHeightFraction = headerHeightFraction,
|
||||
heroContent = heroContent
|
||||
)
|
||||
}
|
||||
bodyContent(contentPadding)
|
||||
}
|
||||
topBar()
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(
|
||||
start = MediaDetailHorizontalPadding,
|
||||
top = MediaDetailHeaderTopPadding,
|
||||
end = MediaDetailHorizontalPadding,
|
||||
bottom = MediaDetailHeaderBottomPadding
|
||||
)
|
||||
.widthIn(max = contentMaxWidth)
|
||||
) {
|
||||
heroContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
val destinations = remember(libraries, selectedDestination) {
|
||||
listOf(
|
||||
TvDrawerDestinationItem(
|
||||
destination = TvDrawerDestination.HOME,
|
||||
label = "Home",
|
||||
icon = Icons.Outlined.Home,
|
||||
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
|
||||
},
|
||||
selected = selectedDestination == TvDrawerDestination.LIBRARIES
|
||||
)
|
||||
add(
|
||||
TvHomeTabItem(
|
||||
destination = TvHomeTabDestination.SEARCH,
|
||||
label = "Search",
|
||||
icon = Icons.Outlined.Search,
|
||||
)
|
||||
)
|
||||
add(
|
||||
TvHomeTabItem(
|
||||
destination = TvHomeTabDestination.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
|
||||
else -> Icons.Outlined.Collections
|
||||
},
|
||||
libraryId = it.id
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -122,7 +122,6 @@ fun TvHomeContent(
|
||||
TvLibraryPosterSection(
|
||||
title = item.name,
|
||||
items = libraryContent[item.id] ?: emptyList(),
|
||||
action = "See All",
|
||||
onFocusedItem = onMediaFocused,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user