refactor(media): centralize detail scaffold content

Move duplicated media detail title, metadata, actions, synopsis, playback, and cast rendering behind MediaDetailScaffoldUiModel so screens only provide model data and optional trailing content.
This commit is contained in:
2026-06-06 12:17:34 +00:00
parent f94bdc5bce
commit c81b228bb2
7 changed files with 534 additions and 462 deletions

View File

@@ -5,44 +5,137 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.ui.common.button.DownloadActionButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
data class MediaDetailScaffoldUiModel(
val imageUrl: String,
val title: String,
val imageContentDescription: String? = null,
val titleFontSize: TextUnit = 32.sp,
val titleLineHeight: TextUnit = 38.sp,
val subtitle: String? = null,
val subtitleFontSize: TextUnit = 14.sp,
val subtitleFontWeight: FontWeight = FontWeight.Medium,
val metadataItems: List<String> = emptyList(),
val highlightedMetadataItem: String? = null,
val actions: MediaDetailActionsUiModel? = null,
val synopsis: MediaDetailSynopsisUiModel? = null,
val playbackSettings: MediaDetailPlaybackSettingsUiModel? = null,
val cast: MediaDetailCastUiModel? = null,
)
data class MediaDetailActionsUiModel(
val primaryAction: MediaDetailPrimaryActionUiModel,
val secondaryActions: List<MediaDetailSecondaryActionUiModel> = emptyList(),
val dividerThickness: Dp? = null,
)
data class MediaDetailPrimaryActionUiModel(
val text: String,
val progress: Float,
val onClick: () -> Unit,
val testTag: String? = null,
)
sealed interface MediaDetailSecondaryActionUiModel {
val testTag: String?
data class Download(
val downloadState: DownloadState,
val onClick: () -> Unit,
override val testTag: String? = null,
) : MediaDetailSecondaryActionUiModel
data class Icon(
val icon: ImageVector,
val onClick: () -> Unit = {},
override val testTag: String? = null,
) : MediaDetailSecondaryActionUiModel
}
data class MediaDetailSynopsisUiModel(
val text: String,
val bodyColor: Color? = null,
val bodyFontSize: TextUnit = 15.sp,
val bodyLineHeight: TextUnit? = 22.sp,
val titleSpacing: Dp = 12.dp,
)
data class MediaDetailPlaybackSettingsUiModel(
val audioTrack: String,
val subtitles: String,
)
data class MediaDetailCastUiModel(
val members: List<CastMember>,
val title: String = "Cast",
val topSpacing: Dp = 0.dp,
val cardWidth: Dp = 96.dp,
val nameFontSize: TextUnit = 12.sp,
val roleFontSize: TextUnit = 10.sp,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MediaDetailScaffold(
imageUrl: String,
uiModel: MediaDetailScaffoldUiModel,
modifier: Modifier = Modifier,
imageHeight: Dp = 320.dp,
contentOverlap: Dp = 24.dp,
contentSpacing: Dp = 16.dp,
topBar: @Composable (scrollBehavior: TopAppBarScrollBehavior) -> Unit = {},
content: @Composable ColumnScope.(Modifier) -> Unit
content: @Composable ColumnScope.(Modifier) -> Unit = {}
) {
val scheme = MaterialTheme.colorScheme
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
val topBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier
modifier = modifier
.fillMaxSize()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection),
containerColor = scheme.background,
@@ -66,8 +159,8 @@ fun MediaDetailScaffold(
.height(imageHeight)
) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
model = uiModel.imageUrl,
contentDescription = uiModel.imageContentDescription,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
@@ -87,8 +180,187 @@ fun MediaDetailScaffold(
)
}
if (!isHomeMediaTransitionActive) {
content(Modifier.padding(horizontal = 16.dp))
val contentModifier = Modifier.padding(horizontal = 16.dp)
MediaDetailScaffoldContent(
uiModel = uiModel,
modifier = contentModifier
)
content(contentModifier)
}
}
}
}
@Composable
private fun ColumnScope.MediaDetailScaffoldContent(
uiModel: MediaDetailScaffoldUiModel,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Text(
text = uiModel.title,
color = scheme.onBackground,
fontSize = uiModel.titleFontSize,
fontWeight = FontWeight.Bold,
lineHeight = uiModel.titleLineHeight,
modifier = modifier
)
uiModel.subtitle?.takeIf { it.isNotBlank() }?.let { subtitle ->
Text(
text = subtitle,
color = scheme.onBackground,
fontSize = uiModel.subtitleFontSize,
fontWeight = uiModel.subtitleFontWeight,
modifier = modifier
)
}
if (uiModel.metadataItems.any { it.isNotBlank() }) {
MediaMetadataFlowRow(
items = uiModel.metadataItems,
highlightedItem = uiModel.highlightedMetadataItem,
modifier = modifier
)
}
uiModel.actions?.let { actions ->
MediaDetailActions(
uiModel = actions,
modifier = modifier
)
}
uiModel.synopsis?.takeIf { it.text.isNotBlank() }?.let { synopsis ->
MediaSynopsis(
synopsis = synopsis.text,
bodyColor = synopsis.bodyColor ?: scheme.onSurfaceVariant,
bodyFontSize = synopsis.bodyFontSize,
bodyLineHeight = synopsis.bodyLineHeight,
titleSpacing = synopsis.titleSpacing,
modifier = modifier
)
}
uiModel.playbackSettings?.let { playbackSettings ->
MediaPlaybackSettings(
backgroundColor = scheme.surface,
foregroundColor = scheme.onSurface,
audioTrack = playbackSettings.audioTrack,
subtitles = playbackSettings.subtitles,
modifier = modifier
)
}
uiModel.cast?.takeIf { it.members.isNotEmpty() }?.let { cast ->
if (cast.topSpacing > 0.dp) {
Spacer(modifier = Modifier.height(cast.topSpacing))
}
Text(
text = cast.title,
color = scheme.onBackground,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = modifier
)
Spacer(modifier = modifier.height(12.dp))
MediaDetailCastRow(
cast = cast,
modifier = modifier
)
}
}
@Composable
private fun MediaDetailCastRow(
cast: MediaDetailCastUiModel,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
LazyRow(
modifier = modifier,
contentPadding = PaddingValues(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(cast.members) { member ->
Column(modifier = Modifier.width(cast.cardWidth)) {
PurefinAsyncImage(
model = member.imageUrl,
contentDescription = null,
modifier = Modifier
.aspectRatio(4f / 5f)
.clip(RoundedCornerShape(12.dp))
.background(scheme.surfaceVariant),
contentScale = ContentScale.Crop,
fallbackIcon = Icons.Outlined.Person
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = member.name,
color = scheme.onBackground,
fontSize = cast.nameFontSize,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = member.role,
color = mutedStrong,
fontSize = cast.roleFontSize,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun MediaDetailActions(
uiModel: MediaDetailActionsUiModel,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier) {
MediaResumeButton(
text = uiModel.primaryAction.text,
progress = uiModel.primaryAction.progress,
onClick = uiModel.primaryAction.onClick,
modifier = Modifier
.sizeIn(maxWidth = 200.dp)
.optionalTestTag(uiModel.primaryAction.testTag)
)
uiModel.dividerThickness?.let { dividerThickness ->
VerticalDivider(
color = MaterialTheme.colorScheme.secondary,
thickness = dividerThickness,
modifier = Modifier
.height(48.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
} ?: Spacer(modifier = Modifier.width(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
uiModel.secondaryActions.forEach { action ->
when (action) {
is MediaDetailSecondaryActionUiModel.Download -> {
DownloadActionButton(
downloadState = action.downloadState,
onClick = action.onClick,
modifier = Modifier.optionalTestTag(action.testTag)
)
}
is MediaDetailSecondaryActionUiModel.Icon -> {
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = action.icon,
height = 48.dp,
onClick = action.onClick,
modifier = Modifier.optionalTestTag(action.testTag)
)
}
}
}
}
}
}
private fun Modifier.optionalTestTag(testTag: String?): Modifier =
if (testTag == null) this else then(Modifier.testTag(testTag))

View File

@@ -1,15 +1,14 @@
package hu.bbara.purefin.ui.screen.episode
import android.content.Intent
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.core.download.DownloadState
@@ -21,10 +20,20 @@ import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.media.MediaDetailActionsUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailCastUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSettingsUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailPrimaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDetails
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDownloadButtonTag
import hu.bbara.purefin.ui.screen.episode.components.EpisodePlayButtonTag
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@@ -88,8 +97,21 @@ private fun EpisodeScreenInternal(
onDownloadClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val playAction = remember(episode.id) {
{
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", episode.id.toString())
context.startActivity(intent)
}
}
MediaDetailScaffold(
imageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
uiModel = episode.toMediaDetailScaffoldUiModel(
onPlayClick = playAction,
downloadState = downloadState,
onDownloadClick = onDownloadClick
),
modifier = modifier,
topBar = { scrollBehavior ->
EpisodeTopBar(
@@ -98,59 +120,46 @@ private fun EpisodeScreenInternal(
scrollBehavior = scrollBehavior
)
},
) { _modifier ->
EpisodeHeroContent(
episode = episode,
modifier = _modifier
)
EpisodeDetails(
episode = episode,
downloadState = downloadState,
onDownloadClick = onDownloadClick,
modifier = _modifier
)
)
}
private fun Episode.toMediaDetailScaffoldUiModel(
onPlayClick: () -> Unit,
downloadState: DownloadState,
onDownloadClick: () -> Unit,
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
title = title,
subtitle = "Episode $index",
metadataItems = listOf(releaseDate, rating, runtime, format),
highlightedMetadataItem = format,
actions = MediaDetailActionsUiModel(
primaryAction = MediaDetailPrimaryActionUiModel(
text = mediaPlayButtonText(progress, watched),
progress = mediaPlaybackProgress(progress),
onClick = onPlayClick,
testTag = EpisodePlayButtonTag
),
secondaryActions = listOf(
MediaDetailSecondaryActionUiModel.Download(
downloadState = downloadState,
onClick = onDownloadClick,
testTag = EpisodeDownloadButtonTag
)
),
dividerThickness = 2.dp
),
synopsis = MediaDetailSynopsisUiModel(text = synopsis),
playbackSettings = MediaDetailPlaybackSettingsUiModel(
audioTrack = "ENG",
subtitles = "ENG"
),
cast = if (cast.isNotEmpty()) {
MediaDetailCastUiModel(members = cast)
} else {
null
}
}
@Composable
private fun EpisodeHeroContent(
episode: Episode,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Text(
text = episode.title,
color = scheme.onBackground,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
lineHeight = 38.sp,
modifier = modifier
)
Text(
text = "Episode ${episode.index}",
color = scheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = modifier
)
EpisodeMetaChips(
episode = episode,
modifier = modifier
)
}
@Composable
private fun EpisodeMetaChips(
episode: Episode,
modifier: Modifier
) {
MediaMetadataFlowRow(
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
highlightedItem = episode.format,
modifier = modifier
)
}
)
@Preview(showBackground = true)
@Composable

View File

@@ -1,11 +1,6 @@
package hu.bbara.purefin.ui.screen.episode.components
import android.content.Intent
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
@@ -17,27 +12,15 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
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.core.download.DownloadState
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.button.DownloadActionButton
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
internal sealed interface EpisodeTopBarShortcut {
@@ -102,76 +85,6 @@ internal fun EpisodeTopBar(
)
}
@Composable
internal fun EpisodeDetails(
episode: Episode,
downloadState: DownloadState,
onDownloadClick: () -> Unit,
modifier: Modifier
) {
val scheme = MaterialTheme.colorScheme
val context = LocalContext.current
val playAction = remember(episode.id) {
{
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", episode.id.toString())
context.startActivity(intent)
}
}
MediaSynopsis(
synopsis = episode.synopsis,
modifier = modifier
)
Row(modifier = modifier) {
MediaResumeButton(
text = mediaPlayButtonText(episode.progress, episode.watched),
progress = mediaPlaybackProgress(episode.progress),
onClick = playAction,
modifier = Modifier
.sizeIn(maxWidth = 200.dp)
.testTag(EpisodePlayButtonTag)
)
VerticalDivider(
color = MaterialTheme.colorScheme.secondary,
thickness = 2.dp,
modifier = Modifier
.height(48.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
Row() {
DownloadActionButton(
downloadState = downloadState,
modifier = Modifier.testTag(EpisodeDownloadButtonTag),
onClick = onDownloadClick
)
}
}
MediaPlaybackSettings(
backgroundColor = MaterialTheme.colorScheme.surface,
foregroundColor = MaterialTheme.colorScheme.onSurface,
//TODO fix it
audioTrack = "ENG",
subtitles = "ENG",
modifier = modifier
)
if (episode.cast.isNotEmpty()) {
Text(
text = "Cast",
color = scheme.onBackground,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = modifier
)
Spacer(modifier = modifier.height(12.dp))
//TODO use it
// MediaCastRow(
// cast = episode.cast
// )
}
}
internal const val EpisodeSeriesButtonTag = "episode-series-button"
internal const val EpisodePlayButtonTag = "episode-play-button"
internal const val EpisodeDownloadButtonTag = "episode-download-button"

View File

@@ -1,14 +1,14 @@
package hu.bbara.purefin.ui.screen.movie
import android.content.Intent
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.core.download.DownloadState
@@ -18,10 +18,20 @@ import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.media.MediaDetailActionsUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailCastUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSettingsUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailPrimaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.movie.components.MovieDetails
import hu.bbara.purefin.ui.screen.movie.components.MovieDownloadButtonTag
import hu.bbara.purefin.ui.screen.movie.components.MoviePlayButtonTag
import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.theme.AppTheme
@@ -73,8 +83,21 @@ private fun MovieScreenInternal(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val playAction = remember(movie.id) {
{
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", movie.id.toString())
context.startActivity(intent)
}
}
MediaDetailScaffold(
imageUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
uiModel = movie.toMediaDetailScaffoldUiModel(
onPlayClick = playAction,
downloadState = downloadState,
onDownloadClick = onDownloadClick
),
modifier = modifier,
topBar = { scrollBehavior ->
MovieTopBar(
@@ -82,42 +105,48 @@ private fun MovieScreenInternal(
scrollBehavior = scrollBehavior
)
},
) { _modifier ->
MovieHeroContent(
movie = movie,
modifier = _modifier
)
MovieDetails(
movie = movie,
downloadState = downloadState,
onDownloadClick = onDownloadClick,
modifier = _modifier
)
}
private fun Movie.toMediaDetailScaffoldUiModel(
onPlayClick: () -> Unit,
downloadState: DownloadState,
onDownloadClick: () -> Unit,
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
title = title,
metadataItems = listOf(year, rating, runtime, format),
highlightedMetadataItem = format,
actions = MediaDetailActionsUiModel(
primaryAction = MediaDetailPrimaryActionUiModel(
text = mediaPlayButtonText(progress, watched),
progress = mediaPlaybackProgress(progress),
onClick = onPlayClick,
testTag = MoviePlayButtonTag
),
secondaryActions = listOf(
MediaDetailSecondaryActionUiModel.Download(
downloadState = downloadState,
onClick = onDownloadClick,
testTag = MovieDownloadButtonTag
)
),
dividerThickness = 4.dp
),
synopsis = MediaDetailSynopsisUiModel(text = synopsis),
playbackSettings = MediaDetailPlaybackSettingsUiModel(
audioTrack = "ENG",
subtitles = "ENG"
),
cast = if (cast.isNotEmpty()) {
MediaDetailCastUiModel(
members = cast,
topSpacing = 24.dp
)
} else {
null
}
}
@Composable
private fun MovieHeroContent(
movie: Movie,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Text(
text = movie.title,
color = scheme.onBackground,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
lineHeight = 38.sp,
modifier = modifier
)
// Spacer(modifier = Modifier.height(8.dp))
MediaMetadataFlowRow(
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
highlightedItem = movie.format,
modifier = modifier
)
}
)
@Preview(showBackground = true)
@Composable

View File

@@ -1,39 +1,13 @@
package hu.bbara.purefin.ui.screen.movie.components
import android.content.Intent
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.button.DownloadActionButton
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
@OptIn(ExperimentalMaterial3Api::class)
@@ -62,76 +36,5 @@ internal fun MovieTopBar(
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun MovieDetails(
movie: Movie,
downloadState: DownloadState,
onDownloadClick: () -> Unit,
modifier: Modifier
) {
val scheme = MaterialTheme.colorScheme
val context = LocalContext.current
val playAction = remember(movie.id) {
{
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", movie.id.toString())
context.startActivity(intent)
}
}
MediaSynopsis(
synopsis = movie.synopsis,
modifier = modifier
)
Row(modifier = modifier) {
MediaResumeButton(
text = mediaPlayButtonText(movie.progress, movie.watched),
progress = mediaPlaybackProgress(movie.progress),
onClick = playAction,
modifier = Modifier
.sizeIn(maxWidth = 200.dp)
.testTag(MoviePlayButtonTag)
)
VerticalDivider(
color = MaterialTheme.colorScheme.secondary,
thickness = 4.dp,
modifier = Modifier
.height(48.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
Row() {
DownloadActionButton(
downloadState = downloadState,
modifier = Modifier.testTag(MovieDownloadButtonTag),
onClick = onDownloadClick
)
}
}
MediaPlaybackSettings(
backgroundColor = MaterialTheme.colorScheme.surface,
foregroundColor = MaterialTheme.colorScheme.onSurface,
audioTrack = "ENG",
subtitles = "ENG",
modifier = modifier
)
if (movie.cast.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Cast",
color = scheme.onBackground,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = modifier
)
Spacer(modifier = modifier.height(12.dp))
//TODO fix
// MediaCastRow(
// cast = movie.cast,
// )
}
}
internal const val MoviePlayButtonTag = "movie-play-button"
internal const val MovieDownloadButtonTag = "movie-download-button"

View File

@@ -1,7 +1,9 @@
package hu.bbara.purefin.ui.screen.series
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import android.content.Intent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -12,7 +14,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -21,19 +24,31 @@ import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.media.MediaDetailActionsUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailCastUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailPrimaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailScaffold
import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.common.media.MediaDetailScaffoldUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSecondaryActionUiModel
import hu.bbara.purefin.ui.common.media.MediaDetailSynopsisUiModel
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.permission.rememberNotificationPermissionGate
import hu.bbara.purefin.ui.screen.series.components.CastRow
import hu.bbara.purefin.ui.screen.series.components.DownloadOptionsBottomSheet
import hu.bbara.purefin.ui.screen.series.components.EpisodeCarousel
import hu.bbara.purefin.ui.screen.series.components.SeasonTabs
import hu.bbara.purefin.ui.screen.series.components.SeriesActionButtons
import hu.bbara.purefin.ui.screen.series.components.SeriesAddButtonTag
import hu.bbara.purefin.ui.screen.series.components.SeriesDownloadButtonTag
import hu.bbara.purefin.ui.screen.series.components.SeriesDownloadOption
import hu.bbara.purefin.ui.screen.series.components.SeriesMetaChips
import hu.bbara.purefin.ui.screen.series.components.SeriesPlayButtonTag
import hu.bbara.purefin.ui.screen.series.components.SeriesTopBar
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import java.util.UUID
@@ -125,6 +140,9 @@ private fun SeriesScreenInternal(
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val context = LocalContext.current
val navigationManager = LocalNavigationManager.current
var showDownloadDialog by remember { mutableStateOf(false) }
fun getDefaultSeason(): Season? {
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.firstOrNull()
@@ -135,6 +153,25 @@ private fun SeriesScreenInternal(
series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched }
?: selectedSeason?.episodes?.firstOrNull()
val playAction = remember(nextUpEpisode, offline) {
nextUpEpisode?.let { episode ->
{
navigationManager.navigate(
Route.EpisodeRoute(
EpisodeDto(
id = episode.id,
seasonId = episode.seasonId,
seriesId = episode.seriesId,
offline = offline,
)
)
)
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", episode.id.toString())
context.startActivity(intent)
}
}
}
LaunchedEffect(series.id, selectedSeason?.id) {
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
@@ -145,7 +182,13 @@ private fun SeriesScreenInternal(
}
MediaDetailScaffold(
imageUrl = ImageUrlBuilder.finishImageUrl(series.imageUrlPrefix, ArtworkKind.PRIMARY),
uiModel = series.toMediaDetailScaffoldUiModel(
selectedSeason = selectedSeason,
nextUpEpisode = nextUpEpisode,
onPlayClick = playAction ?: {},
onDownloadClick = { showDownloadDialog = true },
bodyColor = scheme.onSurface
),
modifier = modifier,
topBar = { scrollBehavior ->
SeriesTopBar(
@@ -154,32 +197,6 @@ private fun SeriesScreenInternal(
)
},
) { _modifier ->
SeriesHeroContent(
series = series,
modifier = _modifier
)
if (selectedSeason != null) {
SeriesActionButtons(
nextUpEpisode = nextUpEpisode,
selectedSeason = selectedSeason,
seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled,
offline = offline,
onDownloadOptionSelected = { option ->
onDownloadOptionSelected(option, selectedSeason)
},
modifier = _modifier
)
}
MediaSynopsis(
synopsis = series.synopsis,
bodyColor = scheme.onSurface,
bodyFontSize = 13.sp,
bodyLineHeight = null,
titleSpacing = 8.dp,
modifier = _modifier
)
if (selectedSeason != null) {
SeasonTabs(
seasons = series.seasons,
@@ -199,40 +216,74 @@ private fun SeriesScreenInternal(
modifier = _modifier
)
}
if (series.cast.isNotEmpty()) {
Text(
text = "Cast",
color = scheme.onBackground,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = _modifier
)
Spacer(modifier = _modifier.height(12.dp))
CastRow(cast = series.cast)
}
}
if (showDownloadDialog && selectedSeason != null) {
DownloadOptionsBottomSheet(
selectedSeasonName = selectedSeason.name,
seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadOptionSelected = {
showDownloadDialog = false
onDownloadOptionSelected(it, selectedSeason)
},
onDismiss = { showDownloadDialog = false }
)
}
}
@Composable
private fun SeriesHeroContent(
series: Series,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Text(
text = series.name,
color = scheme.onBackground,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
lineHeight = 36.sp,
modifier = modifier
)
SeriesMetaChips(
series = series,
modifier = modifier
)
}
private fun Series.toMediaDetailScaffoldUiModel(
selectedSeason: Season?,
nextUpEpisode: Episode?,
onPlayClick: () -> Unit,
onDownloadClick: () -> Unit,
bodyColor: Color,
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
title = name,
titleFontSize = 30.sp,
titleLineHeight = 36.sp,
metadataItems = listOf(year, "$seasonCount Seasons"),
actions = selectedSeason?.let {
MediaDetailActionsUiModel(
primaryAction = MediaDetailPrimaryActionUiModel(
text = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched),
progress = mediaPlaybackProgress(nextUpEpisode?.progress),
onClick = onPlayClick,
testTag = SeriesPlayButtonTag
),
secondaryActions = listOf(
MediaDetailSecondaryActionUiModel.Icon(
icon = Icons.Outlined.Add,
testTag = SeriesAddButtonTag
),
MediaDetailSecondaryActionUiModel.Icon(
icon = Icons.Outlined.Download,
onClick = onDownloadClick,
testTag = SeriesDownloadButtonTag
)
)
)
},
synopsis = MediaDetailSynopsisUiModel(
text = synopsis,
bodyColor = bodyColor,
bodyFontSize = 13.sp,
bodyLineHeight = null,
titleSpacing = 8.dp
),
cast = if (cast.isNotEmpty()) {
MediaDetailCastUiModel(
members = cast,
cardWidth = 84.dp,
nameFontSize = 11.sp,
roleFontSize = 10.sp
)
} else {
null
}
)
private fun SeriesDownloadOption.canPerform(
seriesDownloadState: DownloadState,
@@ -250,4 +301,4 @@ private fun SeriesDownloadOption.requiresNotificationPermission(): Boolean = whe
SeriesDownloadOption.SERIES,
SeriesDownloadOption.SMART -> true
SeriesDownloadOption.DELETE_SMART -> false
}
}

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.ui.screen.series.components
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -8,16 +7,13 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.AutoAwesome
@@ -45,7 +41,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -56,23 +51,13 @@ import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
@OptIn(ExperimentalMaterial3Api::class)
@@ -100,96 +85,6 @@ internal fun SeriesTopBar(
)
}
@Composable
internal fun SeriesMetaChips(
series: Series,
modifier: Modifier = Modifier
) {
MediaMetadataFlowRow(
items = listOf(series.year, "${series.seasonCount} Seasons"),
modifier = modifier
)
}
@Composable
internal fun SeriesActionButtons(
nextUpEpisode: Episode?,
selectedSeason: Season,
seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState,
isSmartDownloadEnabled: Boolean,
offline: Boolean,
onDownloadOptionSelected: (SeriesDownloadOption) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val navigationManager = LocalNavigationManager.current
var showDownloadDialog by remember { mutableStateOf(false) }
val playAction = remember(nextUpEpisode, offline) {
nextUpEpisode?.let { episode ->
{
navigationManager.navigate(
Route.EpisodeRoute(
EpisodeDto(
id = episode.id,
seasonId = episode.seasonId,
seriesId = episode.seriesId,
offline = offline,
)
)
)
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", episode.id.toString())
context.startActivity(intent)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
MediaResumeButton(
text = mediaPlayButtonText(nextUpEpisode?.progress, nextUpEpisode?.watched),
progress = mediaPlaybackProgress(nextUpEpisode?.progress),
onClick = playAction ?: {},
modifier = Modifier
.sizeIn(maxWidth = 200.dp)
.testTag(SeriesPlayButtonTag)
)
Spacer(modifier = Modifier.width(12.dp))
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = Icons.Outlined.Add,
height = 48.dp,
modifier = Modifier.testTag(SeriesAddButtonTag)
)
Spacer(modifier = Modifier.width(12.dp))
MediaActionButton(
backgroundColor = MaterialTheme.colorScheme.surface,
iconColor = MaterialTheme.colorScheme.onSurface,
icon = Icons.Outlined.Download,
height = 48.dp,
modifier = Modifier.testTag(SeriesDownloadButtonTag),
onClick = { showDownloadDialog = true }
)
}
if (showDownloadDialog) {
DownloadOptionsBottomSheet(
selectedSeasonName = selectedSeason.name,
seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled,
onDownloadOptionSelected = {
showDownloadDialog = false
onDownloadOptionSelected(it)
},
onDismiss = { showDownloadDialog = false }
)
}
}
internal enum class SeriesDownloadOption {
SEASON,
SERIES,
@@ -199,7 +94,7 @@ internal enum class SeriesDownloadOption {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DownloadOptionsBottomSheet(
internal fun DownloadOptionsBottomSheet(
selectedSeasonName: String,
seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState,