mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
fix(tv): focus next up episode in series details
This commit is contained in:
@@ -17,7 +17,7 @@ import hu.bbara.purefin.model.Season
|
|||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
||||||
import hu.bbara.purefin.ui.screen.series.components.SeriesPlayButtonTag
|
import hu.bbara.purefin.ui.screen.series.components.SeriesNextUpEpisodeCardTag
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -30,7 +30,7 @@ class SeriesScreenContentTest {
|
|||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
|
fun seriesScreenContent_focusesNextUpEpisode_whenNextUpExists() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
TvSeriesScreenContent(
|
TvSeriesScreenContent(
|
||||||
@@ -44,8 +44,7 @@ class SeriesScreenContentTest {
|
|||||||
|
|
||||||
composeRule.onNodeWithText("Severance").assertIsDisplayed()
|
composeRule.onNodeWithText("Severance").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
|
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsDisplayed()
|
||||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
|
|
||||||
.assertIsFocused()
|
.assertIsFocused()
|
||||||
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
|
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
|
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
|
||||||
@@ -66,7 +65,6 @@ class SeriesScreenContentTest {
|
|||||||
composeRule.waitForIdle()
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
|
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
.assertIsFocused()
|
.assertIsFocused()
|
||||||
@@ -87,7 +85,7 @@ class SeriesScreenContentTest {
|
|||||||
|
|
||||||
composeRule.waitForIdle()
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
|
composeRule.onNodeWithTag(SeriesNextUpEpisodeCardTag).assertIsFocused()
|
||||||
composeRule.onRoot().performKeyInput {
|
composeRule.onRoot().performKeyInput {
|
||||||
pressKey(Key.DirectionCenter)
|
pressKey(Key.DirectionCenter)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package hu.bbara.purefin.ui.screen.movie
|
package hu.bbara.purefin.ui.screen.movie
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -11,14 +11,11 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
|
import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.navigation.MovieDto
|
import hu.bbara.purefin.navigation.MovieDto
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
|
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
|
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
||||||
@@ -67,7 +64,13 @@ internal fun TvMovieScreenContent(
|
|||||||
) {
|
) {
|
||||||
TvMediaDetailBodyBox(
|
TvMediaDetailBodyBox(
|
||||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
||||||
modifier = it
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
heightFraction = 1f
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = MediaDetailHorizontalPadding)
|
||||||
) {
|
) {
|
||||||
TvMovieHeroSection(
|
TvMovieHeroSection(
|
||||||
movie = movie,
|
movie = movie,
|
||||||
@@ -75,30 +78,6 @@ internal fun TvMovieScreenContent(
|
|||||||
playFocusRequester = playFocusRequester,
|
playFocusRequester = playFocusRequester,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
}
|
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
MediaDetailOverviewSection(
|
|
||||||
synopsis = movie.synopsis,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
|
||||||
MediaDetailPlaybackSection(
|
|
||||||
audioTrack = "ENG",
|
|
||||||
subtitles = "ENG",
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
if (movie.cast.isNotEmpty()) {
|
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
|
||||||
MediaDetailSectionTitle(text = "Cast")
|
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
|
||||||
// MediaCastRow(cast = movie.cast)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -17,11 +17,11 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
||||||
|
|
||||||
internal const val MoviePlayButtonTag = "movie-play-button"
|
internal const val MoviePlayButtonTag = "movie-play-button"
|
||||||
|
|
||||||
@@ -33,11 +33,12 @@ internal fun TvMovieHeroSection(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.padding(top = 24.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.widthIn(max = 560.dp)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = movie.title,
|
text = movie.title,
|
||||||
@@ -53,7 +54,40 @@ internal fun TvMovieHeroSection(
|
|||||||
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
|
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
|
||||||
highlightedItem = movie.format
|
highlightedItem = movie.format
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = "Overview",
|
||||||
|
color = scheme.onBackground,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = movie.synopsis,
|
||||||
|
color = mutedStrong,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.4f)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = "Playback",
|
||||||
|
color = scheme.onBackground,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = "ENG • Subtitles ENG",
|
||||||
|
color = mutedStrong,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
MediaResumeButton(
|
MediaResumeButton(
|
||||||
text = mediaPlayButtonText(movie.progress, movie.watched),
|
text = mediaPlayButtonText(movie.progress, movie.watched),
|
||||||
progress = mediaPlaybackProgress(movie.progress),
|
progress = mediaPlaybackProgress(movie.progress),
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package hu.bbara.purefin.ui.screen.series
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -17,15 +19,14 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.feature.content.series.SeriesViewModel
|
import hu.bbara.purefin.feature.content.series.SeriesViewModel
|
||||||
|
import hu.bbara.purefin.model.Episode
|
||||||
import hu.bbara.purefin.model.Season
|
import hu.bbara.purefin.model.Season
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import hu.bbara.purefin.navigation.SeriesDto
|
import hu.bbara.purefin.navigation.SeriesDto
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
|
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
|
||||||
import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
|
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
|
||||||
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
|
||||||
import hu.bbara.purefin.ui.screen.series.components.CastRow
|
|
||||||
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
|
||||||
import hu.bbara.purefin.ui.screen.series.components.TvEpisodeCarousel
|
import hu.bbara.purefin.ui.screen.series.components.TvEpisodeCarousel
|
||||||
import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
|
import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
|
||||||
@@ -63,19 +64,17 @@ internal fun TvSeriesScreenContent(
|
|||||||
onPlayEpisode: (UUID) -> Unit,
|
onPlayEpisode: (UUID) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var selectedSeason by remember(series.id) { mutableStateOf(series.defaultSeason()) }
|
|
||||||
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
|
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
var selectedSeason by remember(series.id, nextUpEpisode?.seasonId) {
|
||||||
|
mutableStateOf(series.defaultSeason(nextUpEpisode))
|
||||||
|
}
|
||||||
val firstContentFocusRequester = remember { FocusRequester() }
|
val firstContentFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(series.id, nextUpEpisode?.id) {
|
LaunchedEffect(series.id, nextUpEpisode?.id) {
|
||||||
|
if (nextUpEpisode != null) return@LaunchedEffect
|
||||||
withFrameNanos { }
|
withFrameNanos { }
|
||||||
if (nextUpEpisode != null) {
|
|
||||||
playFocusRequester.requestFocus()
|
|
||||||
} else {
|
|
||||||
firstContentFocusRequester.requestFocus()
|
firstContentFocusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
TvMediaDetailScaffold(
|
TvMediaDetailScaffold(
|
||||||
resetScrollKey = series.id,
|
resetScrollKey = series.id,
|
||||||
@@ -83,52 +82,44 @@ internal fun TvSeriesScreenContent(
|
|||||||
) {
|
) {
|
||||||
TvMediaDetailBodyBox(
|
TvMediaDetailBodyBox(
|
||||||
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
||||||
modifier = it
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
heightFraction = 1f
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = MediaDetailHorizontalPadding)
|
||||||
) {
|
) {
|
||||||
TvSeriesHeroSection(
|
TvSeriesHeroSection(
|
||||||
series = series,
|
series = series,
|
||||||
nextUpEpisode = nextUpEpisode,
|
|
||||||
onPlayEpisode = { onPlayEpisode(it.id) },
|
|
||||||
playFocusRequester = playFocusRequester,
|
|
||||||
firstContentFocusRequester = firstContentFocusRequester,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(18.dp))
|
||||||
}
|
|
||||||
Column(modifier = it.fillMaxWidth()) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
MediaDetailOverviewSection(
|
|
||||||
synopsis = series.synopsis,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
TvSeasonTabs(
|
TvSeasonTabs(
|
||||||
seasons = series.seasons,
|
seasons = series.seasons,
|
||||||
selectedSeason = selectedSeason,
|
selectedSeason = selectedSeason,
|
||||||
firstItemFocusRequester = firstContentFocusRequester,
|
firstItemFocusRequester = firstContentFocusRequester,
|
||||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||||
onSelect = { selectedSeason = it },
|
onSelect = { selectedSeason = it },
|
||||||
modifier = it
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
TvEpisodeCarousel(
|
TvEpisodeCarousel(
|
||||||
episodes = selectedSeason.episodes,
|
episodes = selectedSeason.episodes,
|
||||||
modifier = it
|
onPlayEpisode = { onPlayEpisode(it.id) },
|
||||||
|
focusedEpisodeId = nextUpEpisode?.id,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
if (series.cast.isNotEmpty()) {
|
|
||||||
Column(modifier = it) {
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
MediaDetailSectionTitle(text = "Cast")
|
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
|
||||||
CastRow(cast = series.cast)
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Series.defaultSeason(): Season {
|
private fun Series.defaultSeason(nextUpEpisode: Episode?): Season {
|
||||||
|
if (nextUpEpisode != null) {
|
||||||
|
seasons.firstOrNull { it.id == nextUpEpisode.seasonId }?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
for (season in seasons) {
|
for (season in seasons) {
|
||||||
if (season.episodes.any { !it.watched }) {
|
if (season.episodes.any { !it.watched }) {
|
||||||
return season
|
return season
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package hu.bbara.purefin.ui.screen.series.components
|
package hu.bbara.purefin.ui.screen.series.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
@@ -16,9 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
@@ -30,16 +31,17 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -50,8 +52,6 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import hu.bbara.purefin.feature.content.series.SeriesViewModel
|
|
||||||
import hu.bbara.purefin.image.ArtworkKind
|
import hu.bbara.purefin.image.ArtworkKind
|
||||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
import hu.bbara.purefin.image.ImageUrlBuilder
|
||||||
import hu.bbara.purefin.model.CastMember
|
import hu.bbara.purefin.model.CastMember
|
||||||
@@ -60,14 +60,13 @@ import hu.bbara.purefin.model.Season
|
|||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
|
import hu.bbara.purefin.ui.screen.home.components.TvHomeRowBringIntoViewSpec
|
||||||
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
|
import java.util.UUID
|
||||||
|
|
||||||
internal const val SeriesPlayButtonTag = "series-play-button"
|
|
||||||
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
|
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
|
||||||
|
internal const val SeriesNextUpEpisodeCardTag = "series-next-up-episode-card"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvSeriesMetaChips(series: Series) {
|
internal fun TvSeriesMetaChips(series: Series) {
|
||||||
@@ -152,26 +151,56 @@ private fun TvSeasonTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TvEpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modifier) {
|
internal fun TvEpisodeCarousel(
|
||||||
|
episodes: List<Episode>,
|
||||||
|
onPlayEpisode: (Episode) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
focusedEpisodeId: UUID? = null
|
||||||
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val focusedEpisodeFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(episodes) {
|
LaunchedEffect(episodes, focusedEpisodeId) {
|
||||||
|
val focusedEpisodeIndex = focusedEpisodeId?.let { id ->
|
||||||
|
episodes.indexOfFirst { it.id == id }
|
||||||
|
} ?: -1
|
||||||
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it }
|
val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it }
|
||||||
if (firstUnwatchedIndex != 0) {
|
val targetIndex = focusedEpisodeIndex.takeIf { it >= 0 } ?: firstUnwatchedIndex
|
||||||
listState.animateScrollToItem(firstUnwatchedIndex)
|
|
||||||
|
if (targetIndex != 0) {
|
||||||
|
listState.scrollToItem(targetIndex)
|
||||||
} else {
|
} else {
|
||||||
listState.scrollToItem(0)
|
listState.scrollToItem(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (focusedEpisodeIndex >= 0) {
|
||||||
|
withFrameNanos { }
|
||||||
|
focusedEpisodeFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
items(episodes, key = { episode -> episode.id }) { episode ->
|
items(episodes, key = { episode -> episode.id }) { episode ->
|
||||||
TvEpisodeCard(episode = episode)
|
TvEpisodeCard(
|
||||||
|
episode = episode,
|
||||||
|
onPlayEpisode = { onPlayEpisode(episode) },
|
||||||
|
modifier = if (episode.id == focusedEpisodeId) {
|
||||||
|
Modifier
|
||||||
|
.focusRequester(focusedEpisodeFocusRequester)
|
||||||
|
.testTag(SeriesNextUpEpisodeCardTag)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,10 +208,6 @@ internal fun TvEpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Mod
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun TvSeriesHeroSection(
|
internal fun TvSeriesHeroSection(
|
||||||
series: Series,
|
series: Series,
|
||||||
nextUpEpisode: Episode?,
|
|
||||||
onPlayEpisode: (Episode) -> Unit,
|
|
||||||
playFocusRequester: FocusRequester,
|
|
||||||
firstContentFocusRequester: FocusRequester,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -190,8 +215,8 @@ internal fun TvSeriesHeroSection(
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.padding(top = 24.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.widthIn(max = 560.dp)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = series.name,
|
text = series.name,
|
||||||
@@ -204,69 +229,31 @@ internal fun TvSeriesHeroSection(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
TvSeriesMetaChips(series = series)
|
TvSeriesMetaChips(series = series)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
if (nextUpEpisode != null) {
|
|
||||||
Text(
|
Text(
|
||||||
text = nextUpEpisode.heroStatusText(),
|
text = "Overview",
|
||||||
color = scheme.primary,
|
color = scheme.onBackground,
|
||||||
fontSize = 12.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = nextUpEpisode.title,
|
text = series.synopsis,
|
||||||
color = scheme.onBackground,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Episode ${nextUpEpisode.index} • ${nextUpEpisode.runtime}",
|
|
||||||
color = mutedStrong,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
MediaResumeButton(
|
|
||||||
text = mediaPlayButtonText(nextUpEpisode.progress, nextUpEpisode.watched),
|
|
||||||
progress = mediaPlaybackProgress(nextUpEpisode.progress),
|
|
||||||
onClick = { onPlayEpisode(nextUpEpisode) },
|
|
||||||
modifier = Modifier
|
|
||||||
.sizeIn(minWidth = 160.dp, maxWidth = 192.dp)
|
|
||||||
.focusRequester(playFocusRequester)
|
|
||||||
.focusProperties { down = firstContentFocusRequester }
|
|
||||||
.testTag(SeriesPlayButtonTag),
|
|
||||||
focusedScale = 1.08f,
|
|
||||||
focusHaloColor = scheme.primary.copy(alpha = 0.22f),
|
|
||||||
focusBorderWidth = 3.dp,
|
|
||||||
focusBorderColor = scheme.onBackground,
|
|
||||||
overlayBorderWidth = 2.dp,
|
|
||||||
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
|
|
||||||
focusable = true,
|
|
||||||
height = 40.dp,
|
|
||||||
textSize = 13.sp,
|
|
||||||
iconSize = 20.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "Choose a season below to start watching.",
|
|
||||||
color = mutedStrong,
|
color = mutedStrong,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.Medium
|
lineHeight = 16.sp,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.4f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TvEpisodeCard(
|
private fun TvEpisodeCard(
|
||||||
viewModel: SeriesViewModel = hiltViewModel(),
|
episode: Episode,
|
||||||
episode: Episode
|
onPlayEpisode: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
@@ -274,11 +261,11 @@ private fun TvEpisodeCard(
|
|||||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.width(260.dp)
|
.width(260.dp)
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.clickable { viewModel.onPlayEpisode(episode.id) },
|
.clickable { onPlayEpisode() },
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -373,7 +360,3 @@ internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
|||||||
// roleSize = 10.sp
|
// roleSize = 10.sp
|
||||||
// )
|
// )
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Episode.heroStatusText(): String {
|
|
||||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Continue Watching" else "Up Next"
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user