mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
Refactor media detail screens with a unified TV scaffold
Introduces `TvMediaDetailScaffold` to standardize the layout of Movie, Series, and Episode detail screens. This refactor implements a consistent two-column header design featuring a hero section on the left and an overview panel on the right, supported by a new horizontal and vertical gradient overlay system. Key changes: - Created `TvMediaDetailScaffold`, `MediaDetailHeaderRow`, and `MediaDetailSectionTitle` common components. - Extracted screen-specific hero and overview logic into internal components for `MovieScreen`, `SeriesScreen`, and `EpisodeScreen`. - Standardized typography, spacing, and focus management across all detail views. - Added comprehensive UI tests for Movie, Series, and Episode content screens. - Improved the "Up Next" and "Library Status" visibility on the Series detail page.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
package hu.bbara.purefin.app.content.episode
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
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
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class EpisodeScreenContentTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun episodeScreenContent_showsSeriesContext_andFocusesPlayButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
EpisodeScreenContent(
|
||||
episode = sampleEpisode(progress = 63.0),
|
||||
seriesTitle = "Severance",
|
||||
topBarShortcut = episodeTopBarShortcut(Route.Home, onSeriesClick = {}),
|
||||
onBack = {},
|
||||
onPlay = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithText("Severance").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("The You You Are").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Episode 4").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed().assertIsFocused()
|
||||
composeRule.onNodeWithText("Series").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun episodeScreenContent_hidesShortcut_whenNoShortcutIsProvided() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
EpisodeScreenContent(
|
||||
episode = sampleEpisode(progress = null),
|
||||
seriesTitle = "Severance",
|
||||
topBarShortcut = episodeTopBarShortcut(
|
||||
previousRoute = Route.PlayerRoute(mediaId = "episode-4"),
|
||||
onSeriesClick = {}
|
||||
),
|
||||
onBack = {},
|
||||
onPlay = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(EpisodePlayButtonTag).assertIsDisplayed()
|
||||
composeRule.onAllNodesWithText("Series").assertCountEquals(0)
|
||||
}
|
||||
|
||||
private fun sampleEpisode(progress: Double?): Episode {
|
||||
val seriesId = UUID.fromString("11111111-1111-1111-1111-111111111111")
|
||||
val seasonId = UUID.fromString("22222222-2222-2222-2222-222222222222")
|
||||
|
||||
return Episode(
|
||||
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
index = 4,
|
||||
title = "The You You Are",
|
||||
synopsis = "Mark is pulled deeper into Lumon's fractured world as the team chases a clue.",
|
||||
releaseDate = "2025",
|
||||
rating = "16+",
|
||||
runtime = "49m",
|
||||
progress = progress,
|
||||
watched = false,
|
||||
format = "4K",
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
|
||||
cast = listOf(
|
||||
CastMember("Adam Scott", "Mark Scout", null)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package hu.bbara.purefin.app.content.movie
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class MovieScreenContentTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun movieScreenContent_showsTvHeader_andFocusesPlayButton() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
MovieScreenContent(
|
||||
movie = sampleMovie(progress = 42.0),
|
||||
onBack = {},
|
||||
onPlay = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithText("Blade Runner 2049").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Playback").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(MoviePlayButtonTag).assertIsDisplayed().assertIsFocused()
|
||||
composeRule.onNodeWithContentDescription("Back").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun sampleMovie(progress: Double?): Movie {
|
||||
return Movie(
|
||||
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||||
libraryId = UUID.fromString("22222222-2222-2222-2222-222222222222"),
|
||||
title = "Blade Runner 2049",
|
||||
progress = progress,
|
||||
watched = false,
|
||||
year = "2017",
|
||||
rating = "16+",
|
||||
runtime = "164m",
|
||||
format = "4K",
|
||||
synopsis = "Officer K uncovers a secret that sends him searching for Rick Deckard.",
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
|
||||
audioTrack = "ENG 5.1",
|
||||
subtitles = "ENG",
|
||||
cast = listOf(
|
||||
CastMember("Ryan Gosling", "K", null)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package hu.bbara.purefin.app.content.series
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Season
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class SeriesScreenContentTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun seriesScreenContent_focusesPrimaryAction_whenNextUpExists() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
SeriesScreenContent(
|
||||
series = sampleSeriesWithEpisodes(),
|
||||
onBack = {},
|
||||
onPlayEpisode = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithText("Severance").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Up Next").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(SeriesPlayButtonTag).assertIsDisplayed().assertIsFocused()
|
||||
composeRule.onNodeWithText("Season 1").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Good News About Hell").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seriesScreenContent_focusesFirstSeason_whenNoPlayableEpisodeExists() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
SeriesScreenContent(
|
||||
series = sampleSeriesWithoutEpisodes(),
|
||||
onBack = {},
|
||||
onPlayEpisode = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithText("Library Status").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag(SeriesFirstSeasonTabTag).assertIsDisplayed().assertIsFocused()
|
||||
}
|
||||
|
||||
private fun sampleSeriesWithEpisodes(): Series {
|
||||
val seriesId = UUID.fromString("11111111-1111-1111-1111-111111111111")
|
||||
val seasonId = UUID.fromString("22222222-2222-2222-2222-222222222222")
|
||||
|
||||
return Series(
|
||||
id = seriesId,
|
||||
libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333"),
|
||||
name = "Severance",
|
||||
synopsis = "Mark leads a team of office workers whose memories have been surgically divided.",
|
||||
year = "2022",
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
|
||||
unwatchedEpisodeCount = 3,
|
||||
seasonCount = 1,
|
||||
seasons = listOf(
|
||||
Season(
|
||||
id = seasonId,
|
||||
seriesId = seriesId,
|
||||
name = "Season 1",
|
||||
index = 1,
|
||||
unwatchedEpisodeCount = 3,
|
||||
episodeCount = 2,
|
||||
episodes = listOf(
|
||||
Episode(
|
||||
id = UUID.fromString("44444444-4444-4444-4444-444444444444"),
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
index = 1,
|
||||
title = "Good News About Hell",
|
||||
synopsis = "Mark is promoted after an unexpected tragedy.",
|
||||
releaseDate = "2022",
|
||||
rating = "16+",
|
||||
runtime = "57m",
|
||||
progress = 18.0,
|
||||
watched = false,
|
||||
format = "4K",
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
|
||||
cast = emptyList()
|
||||
),
|
||||
Episode(
|
||||
id = UUID.fromString("55555555-5555-5555-5555-555555555555"),
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
index = 2,
|
||||
title = "Half Loop",
|
||||
synopsis = "Mark takes the team out for a sanctioned dinner.",
|
||||
releaseDate = "2022",
|
||||
rating = "16+",
|
||||
runtime = "51m",
|
||||
progress = null,
|
||||
watched = false,
|
||||
format = "4K",
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
|
||||
cast = emptyList()
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
cast = listOf(
|
||||
CastMember("Adam Scott", "Mark Scout", null)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun sampleSeriesWithoutEpisodes(): Series {
|
||||
val seriesId = UUID.fromString("66666666-6666-6666-6666-666666666666")
|
||||
|
||||
return Series(
|
||||
id = seriesId,
|
||||
libraryId = UUID.fromString("77777777-7777-7777-7777-777777777777"),
|
||||
name = "Foundation",
|
||||
synopsis = "A band of exiles works to preserve knowledge through the fall of an empire.",
|
||||
year = "2021",
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
|
||||
unwatchedEpisodeCount = 0,
|
||||
seasonCount = 1,
|
||||
seasons = listOf(
|
||||
Season(
|
||||
id = UUID.fromString("88888888-8888-8888-8888-888888888888"),
|
||||
seriesId = seriesId,
|
||||
name = "Season 1",
|
||||
index = 1,
|
||||
unwatchedEpisodeCount = 0,
|
||||
episodeCount = 0,
|
||||
episodes = emptyList()
|
||||
)
|
||||
),
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,35 @@
|
||||
package hu.bbara.purefin.app.content.episode
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
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 androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.common.ui.MediaMetaChip
|
||||
import hu.bbara.purefin.common.ui.MediaSynopsis
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailsTopBarShortcut
|
||||
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
|
||||
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
|
||||
@@ -40,3 +64,117 @@ internal fun EpisodeTopBar(
|
||||
downFocusRequester = downFocusRequester
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun EpisodeHeroSection(
|
||||
episode: Episode,
|
||||
seriesTitle: String?,
|
||||
onPlay: () -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.82f)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 760.dp)
|
||||
) {
|
||||
if (!seriesTitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = seriesTitle,
|
||||
color = scheme.primary,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
Text(
|
||||
text = episode.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 42.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 48.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Episode ${episode.index}",
|
||||
color = mutedStrong,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MediaMetaChip(text = episode.releaseDate)
|
||||
MediaMetaChip(text = episode.rating)
|
||||
MediaMetaChip(text = episode.runtime)
|
||||
MediaMetaChip(
|
||||
text = episode.format,
|
||||
background = scheme.primary.copy(alpha = 0.2f),
|
||||
border = scheme.primary.copy(alpha = 0.35f),
|
||||
textColor = scheme.primary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaResumeButton(
|
||||
text = episode.playButtonText(),
|
||||
progress = episode.progress?.div(100)?.toFloat() ?: 0f,
|
||||
onClick = onPlay,
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
||||
.focusRequester(playFocusRequester)
|
||||
.testTag(EpisodePlayButtonTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EpisodeOverviewPanel(
|
||||
episode: Episode,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f)
|
||||
|
||||
Column(modifier = modifier) {
|
||||
MediaSynopsis(
|
||||
synopsis = episode.synopsis,
|
||||
title = "Overview",
|
||||
titleColor = scheme.onSurface,
|
||||
bodyColor = mutedStrong,
|
||||
titleFontSize = 20.sp,
|
||||
bodyFontSize = 16.sp,
|
||||
bodyLineHeight = 24.sp,
|
||||
titleSpacing = 10.dp,
|
||||
collapsedLines = 5,
|
||||
collapseInitially = false
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Playback",
|
||||
color = scheme.onSurface,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
MediaPlaybackSettings(
|
||||
backgroundColor = scheme.surfaceContainerHigh,
|
||||
foregroundColor = scheme.onSurface,
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Episode.playButtonText(): String {
|
||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
|
||||
}
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
package hu.bbara.purefin.app.content.episode
|
||||
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -23,18 +10,13 @@ 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.focus.focusRequester
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.MediaSynopsis
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
||||
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
|
||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailHeaderRow
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.core.data.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
|
||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
||||
@@ -61,6 +43,7 @@ fun EpisodeScreen(
|
||||
}
|
||||
|
||||
val episode = viewModel.episode.collectAsState()
|
||||
val seriesTitle = viewModel.seriesTitle.collectAsState()
|
||||
val selectedEpisode = episode.value
|
||||
|
||||
if (selectedEpisode == null) {
|
||||
@@ -68,8 +51,9 @@ fun EpisodeScreen(
|
||||
return
|
||||
}
|
||||
|
||||
EpisodeScreenInternal(
|
||||
EpisodeScreenContent(
|
||||
episode = selectedEpisode,
|
||||
seriesTitle = seriesTitle.value,
|
||||
topBarShortcut = remember(previousRoute, viewModel) {
|
||||
episodeTopBarShortcut(
|
||||
previousRoute = previousRoute,
|
||||
@@ -88,122 +72,63 @@ fun EpisodeScreen(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun EpisodeScreenInternal(
|
||||
internal fun EpisodeScreenContent(
|
||||
episode: Episode,
|
||||
seriesTitle: String?,
|
||||
topBarShortcut: EpisodeTopBarShortcut?,
|
||||
onBack: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val hPad = Modifier.padding(horizontal = 16.dp)
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(episode.id) {
|
||||
playFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
MediaHero(
|
||||
imageUrl = episode.heroImageUrl,
|
||||
backgroundColor = scheme.background,
|
||||
heightFraction = 0.30f,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
item {
|
||||
Column(modifier = hPad) {
|
||||
Text(
|
||||
text = episode.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 38.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Episode ${episode.index}",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MediaMetaChip(text = episode.releaseDate)
|
||||
MediaMetaChip(text = episode.rating)
|
||||
MediaMetaChip(text = episode.runtime)
|
||||
MediaMetaChip(
|
||||
text = episode.format,
|
||||
background = scheme.primary.copy(alpha = 0.2f),
|
||||
border = scheme.primary.copy(alpha = 0.3f),
|
||||
textColor = scheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaSynopsis(
|
||||
synopsis = episode.synopsis,
|
||||
modifier = hPad
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Row(modifier = hPad) {
|
||||
MediaResumeButton(
|
||||
text = if (episode.progress == null) "Play" else "Resume",
|
||||
progress = episode.progress?.div(100)?.toFloat() ?: 0f,
|
||||
onClick = onPlay,
|
||||
modifier = Modifier.sizeIn(maxWidth = 200.dp).focusRequester(playFocusRequester)
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaPlaybackSettings(
|
||||
backgroundColor = scheme.surface,
|
||||
foregroundColor = scheme.onSurface,
|
||||
audioTrack = "ENG",
|
||||
subtitles = "ENG",
|
||||
modifier = hPad
|
||||
)
|
||||
}
|
||||
if (episode.cast.isNotEmpty()) {
|
||||
item {
|
||||
Column(modifier = hPad) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Cast",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
MediaCastRow(cast = episode.cast)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TvMediaDetailScaffold(
|
||||
heroImageUrl = episode.heroImageUrl,
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
EpisodeTopBar(
|
||||
onBack = onBack,
|
||||
shortcut = topBarShortcut,
|
||||
downFocusRequester = playFocusRequester,
|
||||
modifier = Modifier.align(Alignment.TopStart)
|
||||
)
|
||||
},
|
||||
heroContent = {
|
||||
MediaDetailHeaderRow(
|
||||
leftContent = { headerModifier ->
|
||||
EpisodeHeroSection(
|
||||
episode = episode,
|
||||
seriesTitle = seriesTitle,
|
||||
onPlay = onPlay,
|
||||
playFocusRequester = playFocusRequester,
|
||||
modifier = headerModifier
|
||||
)
|
||||
},
|
||||
rightContent = { panelModifier ->
|
||||
EpisodeOverviewPanel(
|
||||
episode = episode,
|
||||
modifier = panelModifier
|
||||
)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
) {
|
||||
if (episode.cast.isNotEmpty()) {
|
||||
item {
|
||||
Column(modifier = it) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
MediaDetailSectionTitle(text = "Cast")
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
MediaCastRow(cast = episode.cast)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
package hu.bbara.purefin.app.content.movie
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
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 androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.common.ui.MediaMetaChip
|
||||
import hu.bbara.purefin.common.ui.MediaSynopsis
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar
|
||||
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
|
||||
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(
|
||||
@@ -17,3 +41,97 @@ internal fun MovieTopBar(
|
||||
downFocusRequester = downFocusRequester
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun MovieHeroSection(
|
||||
movie: Movie,
|
||||
onPlay: () -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 760.dp)
|
||||
) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 42.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 48.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MediaMetaChip(text = movie.year)
|
||||
MediaMetaChip(text = movie.rating)
|
||||
MediaMetaChip(text = movie.runtime)
|
||||
MediaMetaChip(
|
||||
text = movie.format,
|
||||
background = scheme.primary.copy(alpha = 0.2f),
|
||||
border = scheme.primary.copy(alpha = 0.35f),
|
||||
textColor = scheme.primary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaResumeButton(
|
||||
text = movie.playButtonText(),
|
||||
progress = movie.progress?.div(100)?.toFloat() ?: 0f,
|
||||
onClick = onPlay,
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
||||
.focusRequester(playFocusRequester)
|
||||
.testTag(MoviePlayButtonTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MovieOverviewPanel(
|
||||
movie: Movie,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f)
|
||||
|
||||
Column(modifier = modifier) {
|
||||
MediaSynopsis(
|
||||
synopsis = movie.synopsis,
|
||||
title = "Overview",
|
||||
titleColor = scheme.onSurface,
|
||||
bodyColor = mutedStrong,
|
||||
titleFontSize = 20.sp,
|
||||
bodyFontSize = 16.sp,
|
||||
bodyLineHeight = 24.sp,
|
||||
titleSpacing = 10.dp,
|
||||
collapsedLines = 5,
|
||||
collapseInitially = false
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Playback",
|
||||
color = scheme.onSurface,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
MediaPlaybackSettings(
|
||||
backgroundColor = scheme.surfaceContainerHigh,
|
||||
foregroundColor = scheme.onSurface,
|
||||
audioTrack = movie.audioTrack,
|
||||
subtitles = movie.subtitles
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Movie.playButtonText(): String {
|
||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
|
||||
}
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
package hu.bbara.purefin.app.content.movie
|
||||
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -23,18 +10,13 @@ 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.focus.focusRequester
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.MediaSynopsis
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
||||
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
|
||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailHeaderRow
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.core.data.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel
|
||||
@@ -50,7 +32,7 @@ fun MovieScreen(
|
||||
val movieItem = viewModel.movie.collectAsState()
|
||||
|
||||
if (movieItem.value != null) {
|
||||
MovieScreenInternal(
|
||||
MovieScreenContent(
|
||||
movie = movieItem.value!!,
|
||||
onBack = viewModel::onBack,
|
||||
onPlay = viewModel::onPlay,
|
||||
@@ -61,113 +43,59 @@ fun MovieScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun MovieScreenInternal(
|
||||
internal fun MovieScreenContent(
|
||||
movie: Movie,
|
||||
onBack: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val hPad = Modifier.padding(horizontal = 16.dp)
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(movie.id) {
|
||||
playFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
MediaHero(
|
||||
imageUrl = movie.heroImageUrl,
|
||||
backgroundColor = scheme.background,
|
||||
heightFraction = 0.30f,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
item {
|
||||
Column(modifier = hPad) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 38.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MediaMetaChip(text = movie.year)
|
||||
MediaMetaChip(text = movie.rating)
|
||||
MediaMetaChip(text = movie.runtime)
|
||||
MediaMetaChip(
|
||||
text = movie.format,
|
||||
background = scheme.primary.copy(alpha = 0.2f),
|
||||
border = scheme.primary.copy(alpha = 0.3f),
|
||||
textColor = scheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaSynopsis(
|
||||
synopsis = movie.synopsis,
|
||||
modifier = hPad
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Row(modifier = hPad) {
|
||||
MediaResumeButton(
|
||||
text = if (movie.progress == null) "Play" else "Resume",
|
||||
progress = movie.progress?.div(100)?.toFloat() ?: 0f,
|
||||
onClick = onPlay,
|
||||
modifier = Modifier.sizeIn(maxWidth = 200.dp).focusRequester(playFocusRequester)
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaPlaybackSettings(
|
||||
backgroundColor = scheme.surface,
|
||||
foregroundColor = scheme.onSurface,
|
||||
audioTrack = movie.audioTrack,
|
||||
subtitles = movie.subtitles,
|
||||
modifier = hPad
|
||||
)
|
||||
}
|
||||
if (movie.cast.isNotEmpty()) {
|
||||
item {
|
||||
Column(modifier = hPad) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Cast",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
MediaCastRow(cast = movie.cast)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TvMediaDetailScaffold(
|
||||
heroImageUrl = movie.heroImageUrl,
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
MovieTopBar(
|
||||
onBack = onBack,
|
||||
downFocusRequester = playFocusRequester,
|
||||
modifier = Modifier.align(Alignment.TopStart)
|
||||
)
|
||||
},
|
||||
heroContent = {
|
||||
MediaDetailHeaderRow(
|
||||
leftContent = { headerModifier ->
|
||||
MovieHeroSection(
|
||||
movie = movie,
|
||||
onPlay = onPlay,
|
||||
playFocusRequester = playFocusRequester,
|
||||
modifier = headerModifier
|
||||
)
|
||||
},
|
||||
rightContent = { panelModifier ->
|
||||
MovieOverviewPanel(
|
||||
movie = movie,
|
||||
modifier = panelModifier
|
||||
)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
) {
|
||||
if (movie.cast.isNotEmpty()) {
|
||||
item {
|
||||
Column(modifier = it) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
MediaDetailSectionTitle(text = "Cast")
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
MediaCastRow(cast = movie.cast)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -39,11 +41,13 @@ 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.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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
|
||||
@@ -51,8 +55,11 @@ 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.MediaSynopsis
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.common.ui.components.MediaProgressBar
|
||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
@@ -61,6 +68,9 @@ import hu.bbara.purefin.core.model.Season
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
|
||||
|
||||
internal const val SeriesPlayButtonTag = "series-play-button"
|
||||
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
|
||||
|
||||
@Composable
|
||||
internal fun SeriesTopBar(
|
||||
onBack: () -> Unit,
|
||||
@@ -93,6 +103,7 @@ internal fun SeasonTabs(
|
||||
selectedSeason: Season?,
|
||||
modifier: Modifier = Modifier,
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
firstItemTestTag: String? = null,
|
||||
onSelect: (Season) -> Unit
|
||||
) {
|
||||
Row(
|
||||
@@ -106,12 +117,22 @@ internal fun SeasonTabs(
|
||||
name = season.name,
|
||||
isSelected = season == selectedSeason,
|
||||
onSelect = { onSelect(season) },
|
||||
modifier = if (index == 0 && firstItemFocusRequester != null) {
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (index == 0 && firstItemFocusRequester != null) {
|
||||
Modifier.focusRequester(firstItemFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (index == 0 && firstItemTestTag != null) {
|
||||
Modifier.testTag(firstItemTestTag)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +197,111 @@ internal fun EpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modif
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SeriesHeroSection(
|
||||
series: Series,
|
||||
nextUpEpisode: Episode?,
|
||||
onPlayEpisode: (Episode) -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
firstContentFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.82f)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 760.dp)
|
||||
) {
|
||||
Text(
|
||||
text = series.name,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 42.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 48.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
SeriesMetaChips(series = series)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
if (nextUpEpisode != null) {
|
||||
MediaResumeButton(
|
||||
text = nextUpEpisode.playButtonText(),
|
||||
progress = nextUpEpisode.progress?.div(100)?.toFloat() ?: 0f,
|
||||
onClick = { onPlayEpisode(nextUpEpisode) },
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
||||
.focusRequester(playFocusRequester)
|
||||
.focusProperties { down = firstContentFocusRequester }
|
||||
.testTag(SeriesPlayButtonTag)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Choose a season below to start watching.",
|
||||
color = mutedStrong,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SeriesOverviewPanel(
|
||||
series: Series,
|
||||
nextUpEpisode: Episode?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.85f)
|
||||
|
||||
Column(modifier = modifier) {
|
||||
MediaSynopsis(
|
||||
synopsis = series.synopsis,
|
||||
title = "Overview",
|
||||
titleColor = scheme.onSurface,
|
||||
bodyColor = mutedStrong,
|
||||
titleFontSize = 20.sp,
|
||||
bodyFontSize = 16.sp,
|
||||
bodyLineHeight = 24.sp,
|
||||
titleSpacing = 10.dp,
|
||||
collapsedLines = 5,
|
||||
collapseInitially = false
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaDetailSectionTitle(
|
||||
text = if (nextUpEpisode != null) "Up Next" else "Library Status",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = if (nextUpEpisode != null) {
|
||||
nextUpEpisode.title
|
||||
} else {
|
||||
"${series.seasonCount} seasons ready to browse"
|
||||
},
|
||||
color = scheme.onSurface,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = if (nextUpEpisode != null) {
|
||||
"Episode ${nextUpEpisode.index} • ${nextUpEpisode.runtime}"
|
||||
} else {
|
||||
"${series.unwatchedEpisodeCount} unwatched episodes"
|
||||
},
|
||||
color = mutedStrong,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeCard(
|
||||
viewModel: SeriesViewModel = hiltViewModel(),
|
||||
@@ -290,3 +416,7 @@ internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
||||
roleSize = 10.sp
|
||||
)
|
||||
}
|
||||
|
||||
private fun Episode.playButtonText(): String {
|
||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
package hu.bbara.purefin.app.content.series
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -21,16 +11,12 @@ 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.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.MediaSynopsis
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailHeaderRow
|
||||
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||
import hu.bbara.purefin.core.data.navigation.SeriesDto
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Season
|
||||
@@ -52,7 +38,7 @@ fun SeriesScreen(
|
||||
|
||||
val seriesData = series.value
|
||||
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
||||
SeriesScreenInternal(
|
||||
SeriesScreenContent(
|
||||
series = seriesData,
|
||||
onBack = viewModel::onBack,
|
||||
onPlayEpisode = viewModel::onPlayEpisode,
|
||||
@@ -64,16 +50,12 @@ fun SeriesScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesScreenInternal(
|
||||
internal fun SeriesScreenContent(
|
||||
series: Series,
|
||||
onBack: () -> Unit,
|
||||
onPlayEpisode: (UUID) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val textMutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
val hPad = Modifier.padding(horizontal = 16.dp)
|
||||
|
||||
fun getDefaultSeason(): Season {
|
||||
for (season in series.seasons) {
|
||||
val firstUnwatchedEpisode = season.episodes.firstOrNull { it.watched.not() }
|
||||
@@ -98,103 +80,67 @@ private fun SeriesScreenInternal(
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
MediaHero(
|
||||
imageUrl = series.heroImageUrl,
|
||||
heightFraction = 0.30f,
|
||||
backgroundColor = scheme.background,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
item {
|
||||
Column(modifier = hPad) {
|
||||
Text(
|
||||
text = series.name,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = 36.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
SeriesMetaChips(series = series)
|
||||
}
|
||||
}
|
||||
if (nextUpEpisode != null) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Row(modifier = hPad) {
|
||||
MediaResumeButton(
|
||||
text = nextUpEpisode.playButtonText(),
|
||||
progress = nextUpEpisode.progress?.div(100)?.toFloat() ?: 0f,
|
||||
onClick = { onPlayEpisode(nextUpEpisode.id) },
|
||||
modifier = Modifier
|
||||
.sizeIn(maxWidth = 200.dp)
|
||||
.focusRequester(playFocusRequester)
|
||||
.focusProperties { down = firstContentFocusRequester }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaSynopsis(
|
||||
synopsis = series.synopsis,
|
||||
bodyColor = textMutedStrong,
|
||||
bodyFontSize = 13.sp,
|
||||
bodyLineHeight = null,
|
||||
titleSpacing = 8.dp,
|
||||
modifier = hPad
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
SeasonTabs(
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason.value,
|
||||
firstItemFocusRequester = firstContentFocusRequester,
|
||||
onSelect = { selectedSeason.value = it },
|
||||
modifier = hPad
|
||||
)
|
||||
}
|
||||
item {
|
||||
EpisodeCarousel(
|
||||
episodes = selectedSeason.value.episodes,
|
||||
modifier = hPad
|
||||
)
|
||||
}
|
||||
if (series.cast.isNotEmpty()) {
|
||||
item {
|
||||
Column(modifier = hPad) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Cast",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
CastRow(cast = series.cast)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TvMediaDetailScaffold(
|
||||
heroImageUrl = series.heroImageUrl,
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
SeriesTopBar(
|
||||
onBack = onBack,
|
||||
downFocusRequester = nextUpEpisode?.let { playFocusRequester } ?: firstContentFocusRequester,
|
||||
modifier = Modifier.align(Alignment.TopStart)
|
||||
)
|
||||
},
|
||||
heroContent = {
|
||||
MediaDetailHeaderRow(
|
||||
leftContent = { headerModifier ->
|
||||
SeriesHeroSection(
|
||||
series = series,
|
||||
nextUpEpisode = nextUpEpisode,
|
||||
onPlayEpisode = { onPlayEpisode(it.id) },
|
||||
playFocusRequester = playFocusRequester,
|
||||
firstContentFocusRequester = firstContentFocusRequester,
|
||||
modifier = headerModifier
|
||||
)
|
||||
},
|
||||
rightContent = { panelModifier ->
|
||||
SeriesOverviewPanel(
|
||||
series = series,
|
||||
nextUpEpisode = nextUpEpisode,
|
||||
modifier = panelModifier
|
||||
)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
SeasonTabs(
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason.value,
|
||||
firstItemFocusRequester = firstContentFocusRequester,
|
||||
firstItemTestTag = SeriesFirstSeasonTabTag,
|
||||
onSelect = { selectedSeason.value = it },
|
||||
modifier = it
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
EpisodeCarousel(
|
||||
episodes = selectedSeason.value.episodes,
|
||||
modifier = it
|
||||
)
|
||||
}
|
||||
if (series.cast.isNotEmpty()) {
|
||||
item {
|
||||
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 Episode.playButtonText(): String {
|
||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package hu.bbara.purefin.common.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
internal val MediaDetailHorizontalPadding = 48.dp
|
||||
private val MediaDetailPanelShape = RoundedCornerShape(28.dp)
|
||||
|
||||
@Composable
|
||||
internal fun TvMediaDetailScaffold(
|
||||
heroImageUrl: String,
|
||||
modifier: Modifier = Modifier,
|
||||
heroHeightFraction: Float = 0.48f,
|
||||
topBar: @Composable BoxScope.() -> Unit,
|
||||
heroContent: @Composable ColumnScope.() -> Unit,
|
||||
bodyContent: LazyListScope.(Modifier) -> Unit = { _ -> }
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val contentPadding = Modifier.padding(horizontal = MediaDetailHorizontalPadding)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
MediaHero(
|
||||
imageUrl = heroImageUrl,
|
||||
backgroundColor = scheme.background,
|
||||
heightFraction = heroHeightFraction,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
MediaDetailHeroGradientOverlay()
|
||||
Column(
|
||||
modifier = contentPadding
|
||||
.padding(top = 104.dp, bottom = 36.dp)
|
||||
) {
|
||||
heroContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
bodyContent(contentPadding)
|
||||
}
|
||||
topBar()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MediaDetailHeaderRow(
|
||||
modifier: Modifier = Modifier,
|
||||
leftWeight: Float = 1.1f,
|
||||
rightWeight: Float = 0.9f,
|
||||
verticalAlignment: Alignment.Vertical = Alignment.Bottom,
|
||||
leftContent: @Composable (Modifier) -> Unit,
|
||||
rightContent: @Composable ColumnScope.(Modifier) -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(40.dp),
|
||||
verticalAlignment = verticalAlignment
|
||||
) {
|
||||
leftContent(Modifier.weight(leftWeight))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(rightWeight)
|
||||
.background(
|
||||
color = scheme.surface.copy(alpha = 0.9f),
|
||||
shape = MediaDetailPanelShape
|
||||
)
|
||||
.padding(28.dp)
|
||||
) {
|
||||
rightContent(Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MediaDetailSectionTitle(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaDetailHeroGradientOverlay() {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
background,
|
||||
background.copy(alpha = 0.95f),
|
||||
background.copy(alpha = 0.7f),
|
||||
background.copy(alpha = 0.15f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
background.copy(alpha = 0.05f),
|
||||
background.copy(alpha = 0.2f),
|
||||
background
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user