mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Refactor TV media detail backdrop layout
This commit is contained in:
@@ -6,13 +6,9 @@ import androidx.compose.ui.test.assertCountEquals
|
|||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.performKeyInput
|
|
||||||
import androidx.compose.ui.test.pressKey
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import hu.bbara.purefin.core.model.CastMember
|
import hu.bbara.purefin.core.model.CastMember
|
||||||
import hu.bbara.purefin.core.model.Episode
|
import hu.bbara.purefin.core.model.Episode
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
@@ -27,13 +23,12 @@ class EpisodeScreenContentTest {
|
|||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun episodeScreenContent_showsSeriesContext_andMovesFromBackToPlayButton() {
|
fun episodeScreenContent_showsSeriesContext_andFocusesPlayButton() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
EpisodeScreenContent(
|
EpisodeScreenContent(
|
||||||
episode = sampleEpisode(progress = 63.0),
|
episode = sampleEpisode(progress = 63.0),
|
||||||
seriesTitle = "Severance",
|
seriesTitle = "Severance",
|
||||||
onBack = {},
|
|
||||||
onPlay = {}
|
onPlay = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -48,12 +43,6 @@ class EpisodeScreenContentTest {
|
|||||||
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
|
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
|
||||||
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed()
|
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Series").assertIsDisplayed()
|
composeRule.onNodeWithText("Series").assertIsDisplayed()
|
||||||
composeRule.onNodeWithContentDescription("Back")
|
|
||||||
.assertIsDisplayed()
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused()
|
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsFocused()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +53,6 @@ class EpisodeScreenContentTest {
|
|||||||
EpisodeScreenContent(
|
EpisodeScreenContent(
|
||||||
episode = sampleEpisode(progress = null),
|
episode = sampleEpisode(progress = null),
|
||||||
seriesTitle = "Severance",
|
seriesTitle = "Severance",
|
||||||
onBack = {},
|
|
||||||
onPlay = {}
|
onPlay = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,9 @@ import androidx.compose.ui.test.assertCountEquals
|
|||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.performKeyInput
|
|
||||||
import androidx.compose.ui.test.pressKey
|
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import hu.bbara.purefin.core.model.CastMember
|
import hu.bbara.purefin.core.model.CastMember
|
||||||
import hu.bbara.purefin.core.model.Movie
|
import hu.bbara.purefin.core.model.Movie
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
@@ -27,12 +23,11 @@ class MovieScreenContentTest {
|
|||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun movieScreenContent_focusesBack_thenMovesToPlayButton() {
|
fun movieScreenContent_focusesPlayButton() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
MovieScreenContent(
|
MovieScreenContent(
|
||||||
movie = sampleMovie(progress = 42.0),
|
movie = sampleMovie(progress = 42.0),
|
||||||
onBack = {},
|
|
||||||
onPlay = {}
|
onPlay = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -43,14 +38,9 @@ class MovieScreenContentTest {
|
|||||||
composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed()
|
composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||||
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
|
composeRule.onAllNodesWithText("Playback").assertCountEquals(1)
|
||||||
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsDisplayed()
|
composeRule.onNodeWithTag(MoviePlayButtonTag)
|
||||||
composeRule.onNodeWithContentDescription("Back")
|
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
.assertIsFocused()
|
.assertIsFocused()
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsFocused()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sampleMovie(progress: Double?): Movie {
|
private fun sampleMovie(progress: Double?): Movie {
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ import androidx.compose.ui.test.ExperimentalTestApi
|
|||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.performKeyInput
|
|
||||||
import androidx.compose.ui.test.pressKey
|
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import hu.bbara.purefin.core.model.CastMember
|
import hu.bbara.purefin.core.model.CastMember
|
||||||
import hu.bbara.purefin.core.model.Episode
|
import hu.bbara.purefin.core.model.Episode
|
||||||
import hu.bbara.purefin.core.model.Season
|
import hu.bbara.purefin.core.model.Season
|
||||||
@@ -27,12 +23,11 @@ class SeriesScreenContentTest {
|
|||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun seriesScreenContent_movesFromBackToPrimaryAction_whenNextUpExists() {
|
fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
SeriesScreenContent(
|
SeriesScreenContent(
|
||||||
series = sampleSeriesWithEpisodes(),
|
series = sampleSeriesWithEpisodes(),
|
||||||
onBack = {},
|
|
||||||
onPlayEpisode = {}
|
onPlayEpisode = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -44,25 +39,18 @@ class SeriesScreenContentTest {
|
|||||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
|
composeRule.onNodeWithText("Continue Watching").assertIsDisplayed()
|
||||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
|
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed()
|
||||||
|
.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()
|
||||||
composeRule.onNodeWithText("Episode 1 • 57m").assertIsDisplayed()
|
composeRule.onNodeWithText("Episode 1 • 57m").assertIsDisplayed()
|
||||||
composeRule.onNodeWithContentDescription("Back")
|
|
||||||
.assertIsDisplayed()
|
|
||||||
.assertIsFocused()
|
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsFocused()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun seriesScreenContent_movesFromBackToFirstSeason_whenNoPlayableEpisodeExists() {
|
fun seriesScreenContent_focusesFirstSeason_whenNoPlayableEpisodeExists() {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
AppTheme {
|
AppTheme {
|
||||||
SeriesScreenContent(
|
SeriesScreenContent(
|
||||||
series = sampleSeriesWithoutEpisodes(),
|
series = sampleSeriesWithoutEpisodes(),
|
||||||
onBack = {},
|
|
||||||
onPlayEpisode = {}
|
onPlayEpisode = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -72,14 +60,9 @@ class SeriesScreenContentTest {
|
|||||||
|
|
||||||
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
composeRule.onNodeWithText("Overview").assertIsDisplayed()
|
||||||
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
|
composeRule.onNodeWithText("Choose a season below to start watching.").assertIsDisplayed()
|
||||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsDisplayed()
|
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag)
|
||||||
composeRule.onNodeWithContentDescription("Back")
|
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
.assertIsFocused()
|
.assertIsFocused()
|
||||||
.performKeyInput {
|
|
||||||
pressKey(Key.DirectionDown)
|
|
||||||
}
|
|
||||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsFocused()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sampleSeriesWithEpisodes(): Series {
|
private fun sampleSeriesWithEpisodes(): Series {
|
||||||
|
|||||||
@@ -18,14 +18,12 @@ import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
|||||||
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
|
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
|
||||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
|
||||||
import hu.bbara.purefin.core.data.navigation.EpisodeDto
|
|
||||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
|
|
||||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
||||||
import hu.bbara.purefin.core.data.navigation.Route
|
import hu.bbara.purefin.core.data.navigation.Route
|
||||||
|
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
|
||||||
|
import hu.bbara.purefin.core.data.navigation.EpisodeDto
|
||||||
import hu.bbara.purefin.core.model.Episode
|
import hu.bbara.purefin.core.model.Episode
|
||||||
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
|
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EpisodeScreen(
|
fun EpisodeScreen(
|
||||||
@@ -34,8 +32,6 @@ fun EpisodeScreen(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val navigationManager = LocalNavigationManager.current
|
val navigationManager = LocalNavigationManager.current
|
||||||
val backStack = LocalNavigationBackStack.current
|
|
||||||
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
|
|
||||||
|
|
||||||
LaunchedEffect(episode) {
|
LaunchedEffect(episode) {
|
||||||
viewModel.selectEpisode(
|
viewModel.selectEpisode(
|
||||||
@@ -57,7 +53,6 @@ fun EpisodeScreen(
|
|||||||
EpisodeScreenContent(
|
EpisodeScreenContent(
|
||||||
episode = selectedEpisode,
|
episode = selectedEpisode,
|
||||||
seriesTitle = seriesTitle.value,
|
seriesTitle = seriesTitle.value,
|
||||||
onBack = viewModel::onBack,
|
|
||||||
onPlay = remember(selectedEpisode.id, navigationManager) {
|
onPlay = remember(selectedEpisode.id, navigationManager) {
|
||||||
{
|
{
|
||||||
navigationManager.navigate(
|
navigationManager.navigate(
|
||||||
@@ -73,7 +68,6 @@ fun EpisodeScreen(
|
|||||||
internal fun EpisodeScreenContent(
|
internal fun EpisodeScreenContent(
|
||||||
episode: Episode,
|
episode: Episode,
|
||||||
seriesTitle: String?,
|
seriesTitle: String?,
|
||||||
onBack: () -> Unit,
|
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -84,9 +78,7 @@ internal fun EpisodeScreenContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
TvMediaDetailScaffold(
|
TvMediaDetailScaffold(
|
||||||
artworkImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY),
|
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(episode.imageUrlPrefix),
|
||||||
artworkWidth = 280.dp,
|
|
||||||
artworkAspectRatio = 16f / 9f,
|
|
||||||
resetScrollKey = episode.id,
|
resetScrollKey = episode.id,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
heroContent = {
|
heroContent = {
|
||||||
|
|||||||
@@ -18,11 +18,10 @@ import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
|||||||
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
|
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection
|
||||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
|
||||||
import hu.bbara.purefin.core.data.navigation.MovieDto
|
import hu.bbara.purefin.core.data.navigation.MovieDto
|
||||||
import hu.bbara.purefin.core.model.Movie
|
import hu.bbara.purefin.core.model.Movie
|
||||||
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
|
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MovieScreen(
|
fun MovieScreen(
|
||||||
@@ -37,7 +36,6 @@ fun MovieScreen(
|
|||||||
if (movieItem.value != null) {
|
if (movieItem.value != null) {
|
||||||
MovieScreenContent(
|
MovieScreenContent(
|
||||||
movie = movieItem.value!!,
|
movie = movieItem.value!!,
|
||||||
onBack = viewModel::onBack,
|
|
||||||
onPlay = viewModel::onPlay,
|
onPlay = viewModel::onPlay,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
@@ -49,7 +47,6 @@ fun MovieScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun MovieScreenContent(
|
internal fun MovieScreenContent(
|
||||||
movie: Movie,
|
movie: Movie,
|
||||||
onBack: () -> Unit,
|
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -60,9 +57,7 @@ internal fun MovieScreenContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
TvMediaDetailScaffold(
|
TvMediaDetailScaffold(
|
||||||
artworkImageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY),
|
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(movie.imageUrlPrefix),
|
||||||
artworkWidth = 200.dp,
|
|
||||||
artworkAspectRatio = 2f / 3f,
|
|
||||||
resetScrollKey = movie.id,
|
resetScrollKey = movie.id,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
heroContent = {
|
heroContent = {
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import androidx.compose.foundation.layout.height
|
|||||||
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
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
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.compose.ui.unit.dp
|
||||||
@@ -17,13 +19,12 @@ import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
|||||||
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection
|
||||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl
|
||||||
import hu.bbara.purefin.core.data.navigation.SeriesDto
|
import hu.bbara.purefin.core.data.navigation.SeriesDto
|
||||||
import hu.bbara.purefin.core.model.Season
|
import hu.bbara.purefin.core.model.Season
|
||||||
import hu.bbara.purefin.core.model.Series
|
import hu.bbara.purefin.core.model.Series
|
||||||
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
|
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SeriesScreen(
|
fun SeriesScreen(
|
||||||
@@ -41,7 +42,6 @@ fun SeriesScreen(
|
|||||||
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
||||||
SeriesScreenContent(
|
SeriesScreenContent(
|
||||||
series = seriesData,
|
series = seriesData,
|
||||||
onBack = viewModel::onBack,
|
|
||||||
onPlayEpisode = viewModel::onPlayEpisode,
|
onPlayEpisode = viewModel::onPlayEpisode,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
@@ -53,23 +53,11 @@ fun SeriesScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun SeriesScreenContent(
|
internal fun SeriesScreenContent(
|
||||||
series: Series,
|
series: Series,
|
||||||
onBack: () -> Unit,
|
|
||||||
onPlayEpisode: (UUID) -> Unit,
|
onPlayEpisode: (UUID) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun getDefaultSeason(): Season {
|
var selectedSeason by remember(series.id) { mutableStateOf(series.defaultSeason()) }
|
||||||
for (season in series.seasons) {
|
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
|
||||||
val firstUnwatchedEpisode = season.episodes.firstOrNull { it.watched.not() }
|
|
||||||
if (firstUnwatchedEpisode != null) return season
|
|
||||||
}
|
|
||||||
return series.seasons.first()
|
|
||||||
}
|
|
||||||
val selectedSeason = remember(series.id) { mutableStateOf(getDefaultSeason()) }
|
|
||||||
val nextUpEpisode = remember(series.id) {
|
|
||||||
series.seasons.firstNotNullOfOrNull { season ->
|
|
||||||
season.episodes.firstOrNull { !it.watched }
|
|
||||||
} ?: series.seasons.firstOrNull()?.episodes?.firstOrNull()
|
|
||||||
}
|
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
val playFocusRequester = remember { FocusRequester() }
|
||||||
val firstContentFocusRequester = remember { FocusRequester() }
|
val firstContentFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
@@ -82,9 +70,7 @@ internal fun SeriesScreenContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
TvMediaDetailScaffold(
|
TvMediaDetailScaffold(
|
||||||
artworkImageUrl = JellyfinImageHelper.finishImageUrl(series.imageUrlPrefix, ImageType.PRIMARY),
|
backgroundImageUrl = tvMediaDetailBackgroundImageUrl(series.imageUrlPrefix),
|
||||||
artworkWidth = 200.dp,
|
|
||||||
artworkAspectRatio = 2f / 3f,
|
|
||||||
resetScrollKey = series.id,
|
resetScrollKey = series.id,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
heroContent = {
|
heroContent = {
|
||||||
@@ -112,17 +98,17 @@ internal fun SeriesScreenContent(
|
|||||||
item {
|
item {
|
||||||
SeasonTabs(
|
SeasonTabs(
|
||||||
seasons = series.seasons,
|
seasons = series.seasons,
|
||||||
selectedSeason = selectedSeason.value,
|
selectedSeason = selectedSeason,
|
||||||
firstItemFocusRequester = firstContentFocusRequester,
|
firstItemFocusRequester = firstContentFocusRequester,
|
||||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||||
onSelect = { selectedSeason.value = it },
|
onSelect = { selectedSeason = it },
|
||||||
modifier = it
|
modifier = it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
EpisodeCarousel(
|
EpisodeCarousel(
|
||||||
episodes = selectedSeason.value.episodes,
|
episodes = selectedSeason.episodes,
|
||||||
modifier = it
|
modifier = it
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -139,3 +125,16 @@ internal fun SeriesScreenContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Series.defaultSeason(): Season {
|
||||||
|
for (season in seasons) {
|
||||||
|
if (season.episodes.any { !it.watched }) {
|
||||||
|
return season
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seasons.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Series.nextUpEpisode() = seasons.firstNotNullOfOrNull { season ->
|
||||||
|
season.episodes.firstOrNull { !it.watched }
|
||||||
|
} ?: seasons.firstOrNull()?.episodes?.firstOrNull()
|
||||||
|
|||||||
@@ -2,18 +2,14 @@ package hu.bbara.purefin.common.ui.components
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
@@ -21,33 +17,24 @@ 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.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
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.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import hu.bbara.purefin.common.ui.MediaSynopsis
|
import hu.bbara.purefin.common.ui.MediaSynopsis
|
||||||
|
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
||||||
internal val MediaDetailHorizontalPadding = 48.dp
|
internal val MediaDetailHorizontalPadding = 48.dp
|
||||||
private val MediaDetailHeaderTopPadding = 104.dp
|
private val MediaDetailHeaderTopPadding = 104.dp
|
||||||
private val MediaDetailHeaderBottomPadding = 36.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
|
@Composable
|
||||||
internal fun TvMediaDetailScaffold(
|
internal fun TvMediaDetailScaffold(
|
||||||
artworkImageUrl: String,
|
backgroundImageUrl: String,
|
||||||
artworkWidth: Dp,
|
|
||||||
artworkAspectRatio: Float,
|
|
||||||
resetScrollKey: Any,
|
resetScrollKey: Any,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
headerHeightFraction: Float = 0.48f,
|
headerHeightFraction: Float = 0.48f,
|
||||||
@@ -62,103 +49,42 @@ internal fun TvMediaDetailScaffold(
|
|||||||
listState.scrollToItem(0)
|
listState.scrollToItem(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(scheme.background)
|
.background(scheme.background)
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
TvMediaDetailHeader(
|
TvMediaDetailHeader(
|
||||||
artworkImageUrl = artworkImageUrl,
|
backgroundImageUrl = backgroundImageUrl,
|
||||||
artworkWidth = artworkWidth,
|
|
||||||
artworkAspectRatio = artworkAspectRatio,
|
|
||||||
headerHeightFraction = headerHeightFraction,
|
headerHeightFraction = headerHeightFraction,
|
||||||
heroContent = heroContent
|
heroContent = heroContent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
bodyContent(contentPadding)
|
bodyContent(contentPadding)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TvMediaDetailHeader(
|
private fun TvMediaDetailHeader(
|
||||||
artworkImageUrl: String,
|
backgroundImageUrl: String,
|
||||||
artworkWidth: Dp,
|
|
||||||
artworkAspectRatio: Float,
|
|
||||||
headerHeightFraction: Float,
|
headerHeightFraction: Float,
|
||||||
heroContent: @Composable ColumnScope.() -> Unit
|
heroContent: @Composable ColumnScope.() -> Unit
|
||||||
) {
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
|
val screenHeight = LocalConfiguration.current.screenHeightDp.dp
|
||||||
val headerHeight = screenHeight * headerHeightFraction
|
val headerHeight = screenHeight * headerHeightFraction
|
||||||
|
|
||||||
BoxWithConstraints(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = headerHeight)
|
.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(
|
PurefinAsyncImage(
|
||||||
model = artworkImageUrl,
|
model = backgroundImageUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
|
||||||
.graphicsLayer { alpha = 0.52f },
|
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
@@ -167,9 +93,10 @@ private fun TvMediaDetailCornerArtwork(
|
|||||||
.background(
|
.background(
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
colorStops = arrayOf(
|
colorStops = arrayOf(
|
||||||
0.0f to scheme.background.copy(alpha = 0.08f),
|
0.0f to scheme.background,
|
||||||
0.55f to scheme.background.copy(alpha = 0.2f),
|
0.28f to scheme.background.copy(alpha = 0.88f),
|
||||||
1.0f to scheme.background.copy(alpha = 0.56f)
|
0.62f to scheme.background.copy(alpha = 0.42f),
|
||||||
|
1.0f to scheme.background.copy(alpha = 0.12f)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -180,14 +107,32 @@ private fun TvMediaDetailCornerArtwork(
|
|||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
colorStops = arrayOf(
|
colorStops = arrayOf(
|
||||||
0.0f to scheme.background.copy(alpha = 0.06f),
|
0.0f to scheme.background.copy(alpha = 0.08f),
|
||||||
0.65f to scheme.background.copy(alpha = 0.22f),
|
0.55f to scheme.background.copy(alpha = 0.16f),
|
||||||
1.0f to scheme.background.copy(alpha = 0.74f)
|
1.0f to scheme.background
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
start = MediaDetailHorizontalPadding,
|
||||||
|
top = MediaDetailHeaderTopPadding,
|
||||||
|
end = MediaDetailHorizontalPadding,
|
||||||
|
bottom = MediaDetailHeaderBottomPadding
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
heroContent()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun tvMediaDetailBackgroundImageUrl(imageUrlPrefix: String?): String {
|
||||||
|
val primaryImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY)
|
||||||
|
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP)
|
||||||
|
return backdropImageUrl.ifBlank { primaryImageUrl }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
Reference in New Issue
Block a user