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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
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.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp 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 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MediaDetailScaffold( fun MediaDetailScaffold(
imageUrl: String, uiModel: MediaDetailScaffoldUiModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageHeight: Dp = 320.dp, imageHeight: Dp = 320.dp,
contentOverlap: Dp = 24.dp, contentOverlap: Dp = 24.dp,
contentSpacing: Dp = 16.dp, contentSpacing: Dp = 16.dp,
topBar: @Composable (scrollBehavior: TopAppBarScrollBehavior) -> Unit = {}, topBar: @Composable (scrollBehavior: TopAppBarScrollBehavior) -> Unit = {},
content: @Composable ColumnScope.(Modifier) -> Unit content: @Composable ColumnScope.(Modifier) -> Unit = {}
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive() val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
val topBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val topBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold( Scaffold(
modifier = Modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(topBarScrollBehavior.nestedScrollConnection), .nestedScroll(topBarScrollBehavior.nestedScrollConnection),
containerColor = scheme.background, containerColor = scheme.background,
@@ -66,8 +159,8 @@ fun MediaDetailScaffold(
.height(imageHeight) .height(imageHeight)
) { ) {
PurefinAsyncImage( PurefinAsyncImage(
model = imageUrl, model = uiModel.imageUrl,
contentDescription = null, contentDescription = uiModel.imageContentDescription,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
@@ -87,8 +180,187 @@ fun MediaDetailScaffold(
) )
} }
if (!isHomeMediaTransitionActive) { 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 package hu.bbara.purefin.ui.screen.episode
import android.content.Intent
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
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.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.tooling.preview.Preview
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.core.download.DownloadState 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.CastMember
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.navigation.LocalNavigationBackStack 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.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.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.EpisodeTopBar
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@@ -88,8 +97,21 @@ private fun EpisodeScreenInternal(
onDownloadClick: () -> Unit, onDownloadClick: () -> Unit,
modifier: Modifier = Modifier, 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( MediaDetailScaffold(
imageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY), uiModel = episode.toMediaDetailScaffoldUiModel(
onPlayClick = playAction,
downloadState = downloadState,
onDownloadClick = onDownloadClick
),
modifier = modifier, modifier = modifier,
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
EpisodeTopBar( EpisodeTopBar(
@@ -98,59 +120,46 @@ private fun EpisodeScreenInternal(
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
) )
}, },
) { _modifier ->
EpisodeHeroContent(
episode = episode,
modifier = _modifier
) )
EpisodeDetails( }
episode = episode,
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, downloadState = downloadState,
onDownloadClick = onDownloadClick, onClick = onDownloadClick,
modifier = _modifier 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) @Preview(showBackground = true)
@Composable @Composable

View File

@@ -1,11 +1,6 @@
package hu.bbara.purefin.ui.screen.episode.components 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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.platform.LocalContext
import androidx.compose.ui.platform.testTag 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
import androidx.compose.ui.unit.sp 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.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 import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
internal sealed interface EpisodeTopBarShortcut { 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 EpisodeSeriesButtonTag = "episode-series-button"
internal const val EpisodePlayButtonTag = "episode-play-button" internal const val EpisodePlayButtonTag = "episode-play-button"
internal const val EpisodeDownloadButtonTag = "episode-download-button" internal const val EpisodeDownloadButtonTag = "episode-download-button"

View File

@@ -1,14 +1,14 @@
package hu.bbara.purefin.ui.screen.movie package hu.bbara.purefin.ui.screen.movie
import android.content.Intent
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
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.remember
import androidx.compose.ui.Modifier 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.tooling.preview.Preview
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.core.download.DownloadState 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.core.navigation.MovieDto
import hu.bbara.purefin.model.CastMember import hu.bbara.purefin.model.CastMember
import hu.bbara.purefin.model.Movie 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.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.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.movie.components.MovieTopBar
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
@@ -73,8 +83,21 @@ private fun MovieScreenInternal(
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, 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( MediaDetailScaffold(
imageUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY), uiModel = movie.toMediaDetailScaffoldUiModel(
onPlayClick = playAction,
downloadState = downloadState,
onDownloadClick = onDownloadClick
),
modifier = modifier, modifier = modifier,
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
MovieTopBar( MovieTopBar(
@@ -82,42 +105,48 @@ private fun MovieScreenInternal(
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
) )
}, },
) { _modifier ->
MovieHeroContent(
movie = movie,
modifier = _modifier
) )
MovieDetails( }
movie = movie,
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, downloadState = downloadState,
onDownloadClick = onDownloadClick, onClick = onDownloadClick,
modifier = _modifier testTag = MovieDownloadButtonTag
) )
} ),
} dividerThickness = 4.dp
),
@Composable synopsis = MediaDetailSynopsisUiModel(text = synopsis),
private fun MovieHeroContent( playbackSettings = MediaDetailPlaybackSettingsUiModel(
movie: Movie, audioTrack = "ENG",
modifier: Modifier = Modifier subtitles = "ENG"
) { ),
val scheme = MaterialTheme.colorScheme cast = if (cast.isNotEmpty()) {
MediaDetailCastUiModel(
Text( members = cast,
text = movie.title, topSpacing = 24.dp
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
) )
} else {
null
} }
)
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable

View File

@@ -1,39 +1,13 @@
package hu.bbara.purefin.ui.screen.movie.components 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.Icons
import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable 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.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 import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
@OptIn(ExperimentalMaterial3Api::class) @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 MoviePlayButtonTag = "movie-play-button"
internal const val MovieDownloadButtonTag = "movie-download-button" internal const val MovieDownloadButtonTag = "movie-download-button"

View File

@@ -1,7 +1,9 @@
package hu.bbara.purefin.ui.screen.series package hu.bbara.purefin.ui.screen.series
import androidx.compose.foundation.layout.Spacer import android.content.Intent
import androidx.compose.foundation.layout.height 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -12,7 +14,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel 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.feature.content.series.SeriesViewModel
import hu.bbara.purefin.core.image.ArtworkKind import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder 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.core.navigation.SeriesDto
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series import hu.bbara.purefin.model.Series
import hu.bbara.purefin.navigation.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.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.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.EpisodeCarousel
import hu.bbara.purefin.ui.screen.series.components.SeasonTabs 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.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.series.components.SeriesTopBar
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import java.util.UUID import java.util.UUID
@@ -125,6 +140,9 @@ private fun SeriesScreenInternal(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val context = LocalContext.current
val navigationManager = LocalNavigationManager.current
var showDownloadDialog by remember { mutableStateOf(false) }
fun getDefaultSeason(): Season? { fun getDefaultSeason(): Season? {
return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.firstOrNull() return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.firstOrNull()
@@ -135,6 +153,25 @@ private fun SeriesScreenInternal(
series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason() series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched } val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched }
?: selectedSeason?.episodes?.firstOrNull() ?: 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) { LaunchedEffect(series.id, selectedSeason?.id) {
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) } selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
@@ -145,7 +182,13 @@ private fun SeriesScreenInternal(
} }
MediaDetailScaffold( 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, modifier = modifier,
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
SeriesTopBar( SeriesTopBar(
@@ -154,32 +197,6 @@ private fun SeriesScreenInternal(
) )
}, },
) { _modifier -> ) { _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) { if (selectedSeason != null) {
SeasonTabs( SeasonTabs(
seasons = series.seasons, seasons = series.seasons,
@@ -199,40 +216,74 @@ private fun SeriesScreenInternal(
modifier = _modifier 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 Series.toMediaDetailScaffoldUiModel(
private fun SeriesHeroContent( selectedSeason: Season?,
series: Series, nextUpEpisode: Episode?,
modifier: Modifier = Modifier, onPlayClick: () -> Unit,
) { onDownloadClick: () -> Unit,
val scheme = MaterialTheme.colorScheme bodyColor: Color,
): MediaDetailScaffoldUiModel = MediaDetailScaffoldUiModel(
Text( imageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY),
text = series.name, title = name,
color = scheme.onBackground, titleFontSize = 30.sp,
fontSize = 30.sp, titleLineHeight = 36.sp,
fontWeight = FontWeight.Bold, metadataItems = listOf(year, "$seasonCount Seasons"),
lineHeight = 36.sp, actions = selectedSeason?.let {
modifier = modifier 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
) )
SeriesMetaChips(
series = series,
modifier = modifier
) )
)
},
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( private fun SeriesDownloadOption.canPerform(
seriesDownloadState: DownloadState, seriesDownloadState: DownloadState,

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.ui.screen.series.components package hu.bbara.purefin.ui.screen.series.components
import android.content.Intent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -8,16 +7,13 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.ArrowBack
import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.AutoAwesome 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.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag 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
@@ -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.feature.content.series.SeriesViewModel
import hu.bbara.purefin.core.image.ArtworkKind import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder 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.CastMember
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.player.PlayerActivity
import hu.bbara.purefin.ui.common.badge.WatchStateBadge import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.bar.MediaProgressBar import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.button.GhostIconButton 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.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 import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
@OptIn(ExperimentalMaterial3Api::class) @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 { internal enum class SeriesDownloadOption {
SEASON, SEASON,
SERIES, SERIES,
@@ -199,7 +94,7 @@ internal enum class SeriesDownloadOption {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun DownloadOptionsBottomSheet( internal fun DownloadOptionsBottomSheet(
selectedSeasonName: String, selectedSeasonName: String,
seriesDownloadState: DownloadState, seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState, seasonDownloadState: DownloadState,