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
|
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.runtime.Composable
|
||||||
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.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.MediaDetailsTopBar
|
||||||
import hu.bbara.purefin.common.ui.components.MediaDetailsTopBarShortcut
|
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.data.navigation.Route
|
||||||
|
import hu.bbara.purefin.core.model.Episode
|
||||||
|
|
||||||
|
internal const val EpisodePlayButtonTag = "episode-play-button"
|
||||||
|
|
||||||
internal sealed interface EpisodeTopBarShortcut {
|
internal sealed interface EpisodeTopBarShortcut {
|
||||||
val label: String
|
val label: String
|
||||||
@@ -40,3 +64,117 @@ internal fun EpisodeTopBar(
|
|||||||
downFocusRequester = downFocusRequester
|
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
|
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.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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
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.lazy.LazyColumn
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
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.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -23,18 +10,13 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
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.focus.focusRequester
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.common.ui.MediaCastRow
|
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.PurefinWaitingScreen
|
||||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
import hu.bbara.purefin.common.ui.components.MediaDetailHeaderRow
|
||||||
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
|
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||||
import hu.bbara.purefin.core.data.navigation.EpisodeDto
|
import hu.bbara.purefin.core.data.navigation.EpisodeDto
|
||||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
|
import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack
|
||||||
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
|
||||||
@@ -61,6 +43,7 @@ fun EpisodeScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val episode = viewModel.episode.collectAsState()
|
val episode = viewModel.episode.collectAsState()
|
||||||
|
val seriesTitle = viewModel.seriesTitle.collectAsState()
|
||||||
val selectedEpisode = episode.value
|
val selectedEpisode = episode.value
|
||||||
|
|
||||||
if (selectedEpisode == null) {
|
if (selectedEpisode == null) {
|
||||||
@@ -68,8 +51,9 @@ fun EpisodeScreen(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
EpisodeScreenInternal(
|
EpisodeScreenContent(
|
||||||
episode = selectedEpisode,
|
episode = selectedEpisode,
|
||||||
|
seriesTitle = seriesTitle.value,
|
||||||
topBarShortcut = remember(previousRoute, viewModel) {
|
topBarShortcut = remember(previousRoute, viewModel) {
|
||||||
episodeTopBarShortcut(
|
episodeTopBarShortcut(
|
||||||
previousRoute = previousRoute,
|
previousRoute = previousRoute,
|
||||||
@@ -88,122 +72,63 @@ fun EpisodeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EpisodeScreenInternal(
|
internal fun EpisodeScreenContent(
|
||||||
episode: Episode,
|
episode: Episode,
|
||||||
|
seriesTitle: String?,
|
||||||
topBarShortcut: EpisodeTopBarShortcut?,
|
topBarShortcut: EpisodeTopBarShortcut?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val hPad = Modifier.padding(horizontal = 16.dp)
|
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
val playFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(episode.id) {
|
||||||
playFocusRequester.requestFocus()
|
playFocusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
TvMediaDetailScaffold(
|
||||||
modifier = modifier
|
heroImageUrl = episode.heroImageUrl,
|
||||||
.fillMaxSize()
|
modifier = modifier,
|
||||||
.background(scheme.background)
|
topBar = {
|
||||||
) {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EpisodeTopBar(
|
EpisodeTopBar(
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
shortcut = topBarShortcut,
|
shortcut = topBarShortcut,
|
||||||
downFocusRequester = playFocusRequester,
|
downFocusRequester = playFocusRequester,
|
||||||
modifier = Modifier.align(Alignment.TopStart)
|
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
|
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.runtime.Composable
|
||||||
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.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.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
|
@Composable
|
||||||
internal fun MovieTopBar(
|
internal fun MovieTopBar(
|
||||||
@@ -17,3 +41,97 @@ internal fun MovieTopBar(
|
|||||||
downFocusRequester = downFocusRequester
|
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
|
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.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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
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.lazy.LazyColumn
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
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.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -23,18 +10,13 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
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.focus.focusRequester
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.common.ui.MediaCastRow
|
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.PurefinWaitingScreen
|
||||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
import hu.bbara.purefin.common.ui.components.MediaDetailHeaderRow
|
||||||
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
|
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold
|
||||||
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
|
||||||
@@ -50,7 +32,7 @@ fun MovieScreen(
|
|||||||
val movieItem = viewModel.movie.collectAsState()
|
val movieItem = viewModel.movie.collectAsState()
|
||||||
|
|
||||||
if (movieItem.value != null) {
|
if (movieItem.value != null) {
|
||||||
MovieScreenInternal(
|
MovieScreenContent(
|
||||||
movie = movieItem.value!!,
|
movie = movieItem.value!!,
|
||||||
onBack = viewModel::onBack,
|
onBack = viewModel::onBack,
|
||||||
onPlay = viewModel::onPlay,
|
onPlay = viewModel::onPlay,
|
||||||
@@ -61,113 +43,59 @@ fun MovieScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MovieScreenInternal(
|
internal fun MovieScreenContent(
|
||||||
movie: Movie,
|
movie: Movie,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val hPad = Modifier.padding(horizontal = 16.dp)
|
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
val playFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(movie.id) {
|
||||||
playFocusRequester.requestFocus()
|
playFocusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
TvMediaDetailScaffold(
|
||||||
modifier = modifier
|
heroImageUrl = movie.heroImageUrl,
|
||||||
.fillMaxSize()
|
modifier = modifier,
|
||||||
.background(scheme.background)
|
topBar = {
|
||||||
) {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MovieTopBar(
|
MovieTopBar(
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
downFocusRequester = playFocusRequester,
|
downFocusRequester = playFocusRequester,
|
||||||
modifier = Modifier.align(Alignment.TopStart)
|
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.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
|
||||||
@@ -39,11 +41,13 @@ 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
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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
|
||||||
@@ -51,8 +55,11 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.common.ui.MediaCastRow
|
import hu.bbara.purefin.common.ui.MediaCastRow
|
||||||
import hu.bbara.purefin.common.ui.MediaMetaChip
|
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.MediaDetailsTopBar
|
||||||
|
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle
|
||||||
import hu.bbara.purefin.common.ui.components.MediaProgressBar
|
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.PurefinAsyncImage
|
||||||
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
|
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
|
||||||
import hu.bbara.purefin.core.model.CastMember
|
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.core.model.Series
|
||||||
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel
|
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
|
@Composable
|
||||||
internal fun SeriesTopBar(
|
internal fun SeriesTopBar(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -93,6 +103,7 @@ internal fun SeasonTabs(
|
|||||||
selectedSeason: Season?,
|
selectedSeason: Season?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
firstItemFocusRequester: FocusRequester? = null,
|
firstItemFocusRequester: FocusRequester? = null,
|
||||||
|
firstItemTestTag: String? = null,
|
||||||
onSelect: (Season) -> Unit
|
onSelect: (Season) -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -106,12 +117,22 @@ internal fun SeasonTabs(
|
|||||||
name = season.name,
|
name = season.name,
|
||||||
isSelected = season == selectedSeason,
|
isSelected = season == selectedSeason,
|
||||||
onSelect = { onSelect(season) },
|
onSelect = { onSelect(season) },
|
||||||
modifier = if (index == 0 && firstItemFocusRequester != null) {
|
modifier = Modifier
|
||||||
|
.then(
|
||||||
|
if (index == 0 && firstItemFocusRequester != null) {
|
||||||
Modifier.focusRequester(firstItemFocusRequester)
|
Modifier.focusRequester(firstItemFocusRequester)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
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
|
@Composable
|
||||||
private fun EpisodeCard(
|
private fun EpisodeCard(
|
||||||
viewModel: SeriesViewModel = hiltViewModel(),
|
viewModel: SeriesViewModel = hiltViewModel(),
|
||||||
@@ -290,3 +416,7 @@ internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
|||||||
roleSize = 10.sp
|
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
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
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.height
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -21,16 +11,12 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
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.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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
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.PurefinWaitingScreen
|
||||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
import hu.bbara.purefin.common.ui.components.MediaDetailHeaderRow
|
||||||
import hu.bbara.purefin.common.ui.components.MediaResumeButton
|
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.data.navigation.SeriesDto
|
||||||
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
|
||||||
@@ -52,7 +38,7 @@ fun SeriesScreen(
|
|||||||
|
|
||||||
val seriesData = series.value
|
val seriesData = series.value
|
||||||
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
|
||||||
SeriesScreenInternal(
|
SeriesScreenContent(
|
||||||
series = seriesData,
|
series = seriesData,
|
||||||
onBack = viewModel::onBack,
|
onBack = viewModel::onBack,
|
||||||
onPlayEpisode = viewModel::onPlayEpisode,
|
onPlayEpisode = viewModel::onPlayEpisode,
|
||||||
@@ -64,16 +50,12 @@ fun SeriesScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SeriesScreenInternal(
|
internal fun SeriesScreenContent(
|
||||||
series: Series,
|
series: Series,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlayEpisode: (UUID) -> Unit,
|
onPlayEpisode: (UUID) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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 {
|
fun getDefaultSeason(): Season {
|
||||||
for (season in series.seasons) {
|
for (season in series.seasons) {
|
||||||
val firstUnwatchedEpisode = season.episodes.firstOrNull { it.watched.not() }
|
val firstUnwatchedEpisode = season.episodes.firstOrNull { it.watched.not() }
|
||||||
@@ -98,103 +80,67 @@ private fun SeriesScreenInternal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
TvMediaDetailScaffold(
|
||||||
modifier = modifier
|
heroImageUrl = series.heroImageUrl,
|
||||||
.fillMaxSize()
|
modifier = modifier,
|
||||||
.background(scheme.background)
|
topBar = {
|
||||||
) {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SeriesTopBar(
|
SeriesTopBar(
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
downFocusRequester = nextUpEpisode?.let { playFocusRequester } ?: firstContentFocusRequester,
|
downFocusRequester = nextUpEpisode?.let { playFocusRequester } ?: firstContentFocusRequester,
|
||||||
modifier = Modifier.align(Alignment.TopStart)
|
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