refactor media components to use shared UI elements and improve consistency

This commit is contained in:
2026-01-21 20:01:02 +01:00
parent a9940d36ce
commit 474d07e49f
7 changed files with 819 additions and 1147 deletions

View File

@@ -1,99 +0,0 @@
package hu.bbara.purefin.app.content.episode
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun EpisodeCard(
episode: EpisodeUiModel,
modifier: Modifier = Modifier,
) {
val colors = rememberEpisodeColors()
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.background(colors.background)
) {
val isWide = maxWidth >= 900.dp
val contentPadding = if (isWide) 32.dp else 20.dp
Box(modifier = Modifier.fillMaxSize()) {
if (isWide) {
Row(modifier = Modifier.fillMaxSize()) {
EpisodeHero(
episode = episode,
height = 300.dp,
isWide = true,
modifier = Modifier
.fillMaxHeight()
.weight(0.5f)
)
EpisodeDetails(
episode = episode,
modifier = Modifier
.weight(0.5f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(
start = contentPadding,
end = contentPadding,
top = 96.dp,
bottom = 32.dp
)
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
EpisodeHero(
episode = episode,
height = 400.dp,
isWide = false,
modifier = Modifier.fillMaxWidth()
)
EpisodeDetails(
episode = episode,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = contentPadding)
.offset(y = (-48).dp)
.padding(bottom = 96.dp)
)
}
}
EpisodeTopBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
)
if (!isWide) {
FloatingPlayButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp)
)
}
}
}
}

View File

@@ -2,60 +2,46 @@ package hu.bbara.purefin.app.content.episode
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
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.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.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil3.compose.AsyncImage
import hu.bbara.purefin.common.ui.MediaActionButtons
import hu.bbara.purefin.common.ui.MediaCastMember
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaFloatingPlayButton
import hu.bbara.purefin.common.ui.MediaGhostIconButton
import hu.bbara.purefin.common.ui.MediaHero
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.MediaPlaybackSettings
import hu.bbara.purefin.common.ui.toMediaDetailColors
import hu.bbara.purefin.player.PlayerActivity
@Composable
@@ -63,100 +49,44 @@ internal fun EpisodeTopBar(
viewModel: EpisodeScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val colors = rememberEpisodeColors().toMediaDetailColors()
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
GhostIconButton(
onClick = { viewModel.onBack() },
MediaGhostIconButton(
colors = colors,
icon = Icons.Outlined.ArrowBack,
contentDescription = "Back"
contentDescription = "Back",
onClick = { viewModel.onBack() }
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast")
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More")
MediaGhostIconButton(colors = colors, icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
MediaGhostIconButton(colors = colors, icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
}
}
}
@Composable
private fun GhostIconButton(
onClick: () -> Unit = {},
icon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier
) {
val colors = rememberEpisodeColors()
Box(
modifier = modifier
.size(40.dp)
.clip(CircleShape)
.background(colors.background.copy(alpha = 0.4f))
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = colors.textPrimary
)
}
}
@Composable
internal fun EpisodeHero(
episode: EpisodeUiModel,
height: Dp,
isWide: Boolean,
onPlayClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colors = rememberEpisodeColors()
Box(
modifier = modifier
.height(height)
.background(colors.background)
) {
AsyncImage(
model = episode.heroImageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
val colors = rememberEpisodeColors().toMediaDetailColors()
MediaHero(
imageUrl = episode.heroImageUrl,
colors = colors,
height = height,
isWide = isWide,
modifier = modifier,
showPlayButton = true,
playButtonSize = if (isWide) 96.dp else 80.dp,
onPlayClick = onPlayClick
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
colors.background.copy(alpha = 0.4f),
colors.background
)
)
)
)
if (isWide) {
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.horizontalGradient(
colors = listOf(
Color.Transparent,
colors.background.copy(alpha = 0.8f)
)
)
)
)
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
PlayButton(size = if (isWide) 96.dp else 80.dp)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@@ -165,7 +95,7 @@ internal fun EpisodeDetails(
episode: EpisodeUiModel,
modifier: Modifier = Modifier
) {
val colors = rememberEpisodeColors()
val colors = rememberEpisodeColors().toMediaDetailColors()
Column(modifier = modifier) {
Text(
text = episode.title,
@@ -179,10 +109,11 @@ internal fun EpisodeDetails(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MetaChip(text = episode.releaseDate)
MetaChip(text = episode.rating)
MetaChip(text = episode.runtime)
MetaChip(
MediaMetaChip(colors = colors, text = episode.releaseDate)
MediaMetaChip(colors = colors, text = episode.rating)
MediaMetaChip(colors = colors, text = episode.runtime)
MediaMetaChip(
colors = colors,
text = episode.format,
background = colors.primary.copy(alpha = 0.2f),
border = colors.primary.copy(alpha = 0.3f),
@@ -191,7 +122,11 @@ internal fun EpisodeDetails(
}
Spacer(modifier = Modifier.height(24.dp))
PlaybackSettings(episode = episode)
MediaPlaybackSettings(
colors = colors,
audioTrack = episode.audioTrack,
subtitles = episode.subtitles
)
Spacer(modifier = Modifier.height(24.dp))
Text(
@@ -209,7 +144,7 @@ internal fun EpisodeDetails(
)
Spacer(modifier = Modifier.height(24.dp))
ActionButtons()
MediaActionButtons(colors = colors)
Spacer(modifier = Modifier.height(28.dp))
Text(
@@ -219,269 +154,108 @@ internal fun EpisodeDetails(
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
CastRow(cast = episode.cast)
}
}
@Composable
private fun MetaChip(
text: String,
background: Color? = null,
border: Color? = null,
textColor: Color? = null
) {
val colors = rememberEpisodeColors()
val resolvedBackground = background ?: colors.surfaceAlt
val resolvedBorder = border ?: Color.Transparent
val resolvedTextColor = textColor ?: colors.textSecondary
Box(
modifier = Modifier
.height(28.dp)
.wrapContentHeight(Alignment.CenterVertically)
.clip(RoundedCornerShape(6.dp))
.background(resolvedBackground)
.border(width = 1.dp, color = resolvedBorder, shape = RoundedCornerShape(6.dp))
.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = resolvedTextColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
MediaCastRow(
colors = colors,
cast = episode.cast.map { it.toMediaCastMember() }
)
}
}
@Composable
private fun PlaybackSettings(episode: EpisodeUiModel) {
val colors = rememberEpisodeColors()
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surfaceAlt)
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(16.dp))
.padding(20.dp)
fun EpisodeCard(
episode: EpisodeUiModel,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Tune,
contentDescription = null,
tint = colors.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Playback Settings",
color = colors.textMuted,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 2.sp
)
}
Spacer(modifier = Modifier.height(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SettingDropdown(
label = "Audio Track",
icon = Icons.Outlined.VolumeUp,
value = episode.audioTrack
)
SettingDropdown(
label = "Subtitles",
icon = Icons.Outlined.ClosedCaption,
value = episode.subtitles
)
}
val colors = rememberEpisodeColors().toMediaDetailColors()
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)
}
}
@Composable
private fun SettingDropdown(
label: String,
icon: ImageVector,
value: String
) {
val colors = rememberEpisodeColors()
Column {
Text(
text = label,
color = colors.textMutedStrong,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 4.dp, bottom = 6.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(colors.surface)
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = icon, contentDescription = null, tint = colors.textMutedStrong)
Spacer(modifier = Modifier.width(10.dp))
Text(text = value, color = colors.textPrimary, fontSize = 14.sp)
}
Icon(imageVector = Icons.Outlined.ExpandMore, contentDescription = null, tint = colors.textMutedStrong)
}
}
}
@Composable
private fun ActionButtons() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ActionButton(
text = "Watchlist",
icon = Icons.Outlined.Add,
modifier = Modifier.weight(1f)
)
ActionButton(
text = "Download",
icon = Icons.Outlined.Download,
modifier = Modifier.weight(1f)
)
}
}
@Composable
private fun ActionButton(
text: String,
icon: ImageVector,
modifier: Modifier = Modifier
) {
val colors = rememberEpisodeColors()
Row(
BoxWithConstraints(
modifier = modifier
.height(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(colors.surfaceAlt.copy(alpha = 0.6f))
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(12.dp))
.clickable { },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(imageVector = icon, contentDescription = null, tint = colors.textPrimary)
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, color = colors.textPrimary, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun CastRow(cast: List<CastMember>) {
val colors = rememberEpisodeColors()
LazyRow(
contentPadding = PaddingValues(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(cast) { member ->
Column(modifier = Modifier.width(96.dp)) {
Box(
modifier = Modifier
.aspectRatio(4f / 5f)
.clip(RoundedCornerShape(12.dp))
.background(colors.surfaceAlt)
) {
if (member.imageUrl == null) {
Box(
modifier = Modifier
.fillMaxSize()
.background(colors.surfaceAlt.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
.background(colors.background)
) {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = null,
tint = colors.textMutedStrong
val isWide = maxWidth >= 900.dp
val contentPadding = if (isWide) 32.dp else 20.dp
Box(modifier = Modifier.fillMaxSize()) {
if (isWide) {
Row(modifier = Modifier.fillMaxSize()) {
EpisodeHero(
episode = episode,
height = 300.dp,
isWide = true,
onPlayClick = playAction,
modifier = Modifier
.fillMaxHeight()
.weight(0.5f)
)
EpisodeDetails(
episode = episode,
modifier = Modifier
.weight(0.5f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(
start = contentPadding,
end = contentPadding,
top = 96.dp,
bottom = 32.dp
)
)
}
} else {
AsyncImage(
model = member.imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = member.name,
color = colors.textPrimary,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = member.role,
color = colors.textMutedStrong,
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun PlayButton(
size: Dp,
modifier: Modifier = Modifier,
viewModel: EpisodeScreenViewModel = hiltViewModel()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
val colors = rememberEpisodeColors()
val context = LocalContext.current
val episodeItem = viewModel.episode.collectAsState()
Box(
modifier = modifier
.size(size)
.shadow(24.dp, CircleShape)
.clip(CircleShape)
.background(colors.primary)
.clickable {
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", episodeItem.value!!.id.toString())
context.startActivity(intent)
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = colors.onPrimary,
modifier = Modifier.size(42.dp)
EpisodeHero(
episode = episode,
height = 400.dp,
isWide = false,
onPlayClick = playAction,
modifier = Modifier.fillMaxWidth()
)
EpisodeDetails(
episode = episode,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = contentPadding)
.offset(y = (-48).dp)
.padding(bottom = 96.dp)
)
}
}
@Composable
internal fun FloatingPlayButton(modifier: Modifier = Modifier) {
val colors = rememberEpisodeColors()
Box(
modifier = modifier
.size(56.dp)
.shadow(20.dp, CircleShape)
.clip(CircleShape)
.background(colors.primary),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = colors.onPrimary
EpisodeTopBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
)
if (!isWide) {
MediaFloatingPlayButton(
colors = colors,
onClick = playAction,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp)
)
}
}
}
}
private fun CastMember.toMediaCastMember() = MediaCastMember(
name = name,
role = role,
imageUrl = imageUrl
)

View File

@@ -1,99 +0,0 @@
package hu.bbara.purefin.app.content.movie
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun MovieCard(
movie: MovieUiModel,
modifier: Modifier = Modifier,
) {
val colors = rememberMovieColors()
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.background(colors.background)
) {
val isWide = maxWidth >= 900.dp
val contentPadding = if (isWide) 32.dp else 20.dp
Box(modifier = Modifier.fillMaxSize()) {
if (isWide) {
Row(modifier = Modifier.fillMaxSize()) {
MovieHero(
movie = movie,
height = 300.dp,
isWide = true,
modifier = Modifier
.fillMaxHeight()
.weight(0.5f)
)
MovieDetails(
movie = movie,
modifier = Modifier
.weight(0.5f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(
start = contentPadding,
end = contentPadding,
top = 96.dp,
bottom = 32.dp
)
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
MovieHero(
movie = movie,
height = 400.dp,
isWide = false,
modifier = Modifier.fillMaxWidth()
)
MovieDetails(
movie = movie,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = contentPadding)
.offset(y = (-48).dp)
.padding(bottom = 96.dp)
)
}
}
MovieTopBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
)
if (!isWide) {
FloatingPlayButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp)
)
}
}
}
}

View File

@@ -2,60 +2,46 @@ package hu.bbara.purefin.app.content.movie
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
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.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.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil3.compose.AsyncImage
import hu.bbara.purefin.common.ui.MediaActionButtons
import hu.bbara.purefin.common.ui.MediaCastMember
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaFloatingPlayButton
import hu.bbara.purefin.common.ui.MediaGhostIconButton
import hu.bbara.purefin.common.ui.MediaHero
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.MediaPlaybackSettings
import hu.bbara.purefin.common.ui.toMediaDetailColors
import hu.bbara.purefin.player.PlayerActivity
@Composable
@@ -63,99 +49,44 @@ internal fun MovieTopBar(
viewModel: MovieScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val colors = rememberMovieColors().toMediaDetailColors()
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
GhostIconButton(
onClick = { viewModel.onBack() },
MediaGhostIconButton(
colors = colors,
icon = Icons.Outlined.ArrowBack,
contentDescription = "Back"
contentDescription = "Back",
onClick = { viewModel.onBack() }
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast")
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More")
MediaGhostIconButton(colors = colors, icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
MediaGhostIconButton(colors = colors, icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
}
}
}
@Composable
private fun GhostIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
val colors = rememberMovieColors()
Box(
modifier = modifier
.size(40.dp)
.clip(CircleShape)
.background(colors.background.copy(alpha = 0.4f))
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = colors.textPrimary
)
}
}
@Composable
internal fun MovieHero(
movie: MovieUiModel,
height: Dp,
isWide: Boolean,
onPlayClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colors = rememberMovieColors()
Box(
modifier = modifier
.height(height)
.background(colors.background)
) {
AsyncImage(
model = movie.heroImageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
val colors = rememberMovieColors().toMediaDetailColors()
MediaHero(
imageUrl = movie.heroImageUrl,
colors = colors,
height = height,
isWide = isWide,
modifier = modifier,
showPlayButton = true,
playButtonSize = if (isWide) 96.dp else 80.dp,
onPlayClick = onPlayClick
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
colors.background.copy(alpha = 0.4f),
colors.background
)
)
)
)
if (isWide) {
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.horizontalGradient(
colors = listOf(
Color.Transparent, colors.background.copy(alpha = 0.8f)
)
)
)
)
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
PlayButton(size = if (isWide) 96.dp else 80.dp)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@@ -164,7 +95,7 @@ internal fun MovieDetails(
movie: MovieUiModel,
modifier: Modifier = Modifier
) {
val colors = rememberMovieColors()
val colors = rememberMovieColors().toMediaDetailColors()
Column(modifier = modifier) {
Text(
text = movie.title,
@@ -178,10 +109,11 @@ internal fun MovieDetails(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MetaChip(text = movie.year)
MetaChip(text = movie.rating)
MetaChip(text = movie.runtime)
MetaChip(
MediaMetaChip(colors = colors, text = movie.year)
MediaMetaChip(colors = colors, text = movie.rating)
MediaMetaChip(colors = colors, text = movie.runtime)
MediaMetaChip(
colors = colors,
text = movie.format,
background = colors.primary.copy(alpha = 0.2f),
border = colors.primary.copy(alpha = 0.3f),
@@ -190,7 +122,11 @@ internal fun MovieDetails(
}
Spacer(modifier = Modifier.height(24.dp))
PlaybackSettings(movie = movie)
MediaPlaybackSettings(
colors = colors,
audioTrack = movie.audioTrack,
subtitles = movie.subtitles
)
Spacer(modifier = Modifier.height(24.dp))
Text(
@@ -208,7 +144,7 @@ internal fun MovieDetails(
)
Spacer(modifier = Modifier.height(24.dp))
ActionButtons()
MediaActionButtons(colors = colors)
Spacer(modifier = Modifier.height(28.dp))
Text(
@@ -218,269 +154,108 @@ internal fun MovieDetails(
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
CastRow(cast = movie.cast)
}
}
@Composable
private fun MetaChip(
text: String,
background: Color? = null,
border: Color? = null,
textColor: Color? = null
) {
val colors = rememberMovieColors()
val resolvedBackground = background ?: colors.surfaceAlt
val resolvedBorder = border ?: Color.Transparent
val resolvedTextColor = textColor ?: colors.textSecondary
Box(
modifier = Modifier
.height(28.dp)
.wrapContentHeight(Alignment.CenterVertically)
.clip(RoundedCornerShape(6.dp))
.background(resolvedBackground)
.border(width = 1.dp, color = resolvedBorder, shape = RoundedCornerShape(6.dp))
.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = resolvedTextColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
MediaCastRow(
colors = colors,
cast = movie.cast.map { it.toMediaCastMember() }
)
}
}
@Composable
private fun PlaybackSettings(movie: MovieUiModel) {
val colors = rememberMovieColors()
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surfaceAlt)
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(16.dp))
.padding(20.dp)
fun MovieCard(
movie: MovieUiModel,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Tune,
contentDescription = null,
tint = colors.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Playback Settings",
color = colors.textMuted,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 2.sp
)
}
Spacer(modifier = Modifier.height(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SettingDropdown(
label = "Audio Track",
icon = Icons.Outlined.VolumeUp,
value = movie.audioTrack
)
SettingDropdown(
label = "Subtitles",
icon = Icons.Outlined.ClosedCaption,
value = movie.subtitles
)
}
val colors = rememberMovieColors().toMediaDetailColors()
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)
}
}
@Composable
private fun SettingDropdown(
label: String,
icon: ImageVector,
value: String
) {
val colors = rememberMovieColors()
Column {
Text(
text = label,
color = colors.textMutedStrong,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 4.dp, bottom = 6.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(colors.surface)
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = icon, contentDescription = null, tint = colors.textMutedStrong)
Spacer(modifier = Modifier.width(10.dp))
Text(text = value, color = colors.textPrimary, fontSize = 14.sp)
}
Icon(imageVector = Icons.Outlined.ExpandMore, contentDescription = null, tint = colors.textMutedStrong)
}
}
}
@Composable
private fun ActionButtons() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ActionButton(
text = "Watchlist",
icon = Icons.Outlined.Add,
modifier = Modifier.weight(1f)
)
ActionButton(
text = "Download",
icon = Icons.Outlined.Download,
modifier = Modifier.weight(1f)
)
}
}
@Composable
private fun ActionButton(
text: String,
icon: ImageVector,
modifier: Modifier = Modifier
) {
val colors = rememberMovieColors()
Row(
BoxWithConstraints(
modifier = modifier
.height(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(colors.surfaceAlt.copy(alpha = 0.6f))
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(12.dp))
.clickable { },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(imageVector = icon, contentDescription = null, tint = colors.textPrimary)
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, color = colors.textPrimary, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun CastRow(cast: List<CastMember>) {
val colors = rememberMovieColors()
LazyRow(
contentPadding = PaddingValues(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(cast) { member ->
Column(modifier = Modifier.width(96.dp)) {
Box(
modifier = Modifier
.aspectRatio(4f / 5f)
.clip(RoundedCornerShape(12.dp))
.background(colors.surfaceAlt)
) {
if (member.imageUrl == null) {
Box(
modifier = Modifier
.fillMaxSize()
.background(colors.surfaceAlt.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
.background(colors.background)
) {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = null,
tint = colors.textMutedStrong
val isWide = maxWidth >= 900.dp
val contentPadding = if (isWide) 32.dp else 20.dp
Box(modifier = Modifier.fillMaxSize()) {
if (isWide) {
Row(modifier = Modifier.fillMaxSize()) {
MovieHero(
movie = movie,
height = 300.dp,
isWide = true,
onPlayClick = playAction,
modifier = Modifier
.fillMaxHeight()
.weight(0.5f)
)
MovieDetails(
movie = movie,
modifier = Modifier
.weight(0.5f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(
start = contentPadding,
end = contentPadding,
top = 96.dp,
bottom = 32.dp
)
)
}
} else {
AsyncImage(
model = member.imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = member.name,
color = colors.textPrimary,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = member.role,
color = colors.textMutedStrong,
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun PlayButton(
size: Dp,
modifier: Modifier = Modifier,
viewModel: MovieScreenViewModel = hiltViewModel()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
val colors = rememberMovieColors()
val context = LocalContext.current
val movieId = viewModel.movie.collectAsState()
Box(
modifier = modifier
.size(size)
.shadow(24.dp, CircleShape)
.clip(CircleShape)
.background(colors.primary)
.clickable {
val intent = Intent(context, PlayerActivity::class.java)
intent.putExtra("MEDIA_ID", movieId.value!!.id.toString())
context.startActivity(intent)
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = colors.onPrimary,
modifier = Modifier.size(42.dp)
MovieHero(
movie = movie,
height = 400.dp,
isWide = false,
onPlayClick = playAction,
modifier = Modifier.fillMaxWidth()
)
MovieDetails(
movie = movie,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = contentPadding)
.offset(y = (-48).dp)
.padding(bottom = 96.dp)
)
}
}
@Composable
internal fun FloatingPlayButton(modifier: Modifier = Modifier) {
val colors = rememberMovieColors()
Box(
modifier = modifier
.size(56.dp)
.shadow(20.dp, CircleShape)
.clip(CircleShape)
.background(colors.primary),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = colors.onPrimary
MovieTopBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
)
if (!isWide) {
MediaFloatingPlayButton(
colors = colors,
onClick = playAction,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp)
)
}
}
}
}
private fun CastMember.toMediaCastMember() = MediaCastMember(
name = name,
role = role,
imageUrl = imageUrl
)

View File

@@ -19,20 +19,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@@ -40,10 +34,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -52,98 +43,68 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil3.compose.AsyncImage
import hu.bbara.purefin.common.ui.MediaActionButtons
import hu.bbara.purefin.common.ui.MediaCastMember
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaGhostIconButton
import hu.bbara.purefin.common.ui.MediaHero
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.toMediaDetailColors
@Composable
internal fun SeriesTopBar(
viewModel: SeriesViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val colors = rememberSeriesColors().toMediaDetailColors()
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
GhostIconButton(
MediaGhostIconButton(
colors = colors,
onClick = { viewModel.onBack() },
icon = Icons.Outlined.ArrowBack,
contentDescription = "Back")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast")
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More")
MediaGhostIconButton(colors = colors, icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
MediaGhostIconButton(colors = colors, icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { })
}
}
}
@Composable
private fun GhostIconButton(
onClick: () -> Unit = {},
icon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier
) {
val colors = rememberSeriesColors()
Box(
modifier = modifier
.size(40.dp)
.clip(CircleShape)
.background(colors.background.copy(alpha = 0.4f))
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = colors.textPrimary
)
}
}
@Composable
internal fun SeriesHero(
imageUrl: String,
height: Dp,
modifier: Modifier = Modifier
) {
val colors = rememberSeriesColors()
Box(
modifier = modifier
.height(height)
.background(colors.background)
) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
val colors = rememberSeriesColors().toMediaDetailColors()
MediaHero(
imageUrl = imageUrl,
colors = colors,
height = height,
isWide = false,
modifier = modifier,
showPlayButton = false,
horizontalGradientOnWide = false
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
colors.background.copy(alpha = 0.4f),
colors.background
)
)
)
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SeriesMetaChips(series: SeriesUiModel) {
val colors = rememberSeriesColors()
val colors = rememberSeriesColors().toMediaDetailColors()
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MetaChip(text = series.year)
MetaChip(text = series.rating)
MetaChip(text = series.seasons)
MetaChip(
MediaMetaChip(colors = colors, text = series.year)
MediaMetaChip(colors = colors, text = series.rating)
MediaMetaChip(colors = colors, text = series.seasons)
MediaMetaChip(
colors = colors,
text = series.format,
background = colors.primary.copy(alpha = 0.2f),
border = colors.primary.copy(alpha = 0.3f),
@@ -152,76 +113,15 @@ internal fun SeriesMetaChips(series: SeriesUiModel) {
}
}
@Composable
private fun MetaChip(
text: String,
background: Color? = null,
border: Color? = null,
textColor: Color? = null
) {
val colors = rememberSeriesColors()
val resolvedBackground = background ?: colors.surfaceAlt
val resolvedBorder = border ?: colors.surfaceBorder
val resolvedTextColor = textColor ?: colors.textSecondary
Box(
modifier = Modifier
.height(28.dp)
.wrapContentHeight(Alignment.CenterVertically)
.clip(RoundedCornerShape(6.dp))
.background(resolvedBackground)
.border(width = 1.dp, color = resolvedBorder, shape = RoundedCornerShape(6.dp))
.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = resolvedTextColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
@Composable
internal fun SeriesActionButtons(modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ActionButton(
text = "Watchlist",
icon = Icons.Outlined.Add,
modifier = Modifier.weight(1f)
val colors = rememberSeriesColors().toMediaDetailColors()
MediaActionButtons(
colors = colors,
modifier = modifier,
height = 44.dp,
textSize = 13.sp
)
ActionButton(
text = "Download",
icon = Icons.Outlined.Download,
modifier = Modifier.weight(1f)
)
}
}
@Composable
private fun ActionButton(
text: String,
icon: ImageVector,
modifier: Modifier = Modifier
) {
val colors = rememberSeriesColors()
Row(
modifier = modifier
.height(44.dp)
.clip(RoundedCornerShape(12.dp))
.background(colors.surfaceAlt.copy(alpha = 0.6f))
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(12.dp))
.clickable { },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(imageVector = icon, contentDescription = null, tint = colors.textPrimary, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, color = colors.textPrimary, fontSize = 13.sp, fontWeight = FontWeight.Bold)
}
}
@Composable
@@ -360,88 +260,19 @@ private fun EpisodeCard(
@Composable
internal fun CastRow(cast: List<SeriesCastMemberUiModel>, modifier: Modifier = Modifier) {
LazyRow(
val colors = rememberSeriesColors().toMediaDetailColors()
MediaCastRow(
colors = colors,
cast = cast.map { it.toMediaCastMember() },
modifier = modifier,
contentPadding = PaddingValues(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(cast) { member ->
CastCard(member = member)
}
}
cardWidth = 84.dp,
nameSize = 11.sp,
roleSize = 10.sp
)
}
@Composable
private fun CastCard(member: SeriesCastMemberUiModel) {
val colors = rememberSeriesColors()
Column(
modifier = Modifier.width(84.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Box(
modifier = Modifier
.aspectRatio(4f / 5f)
.clip(RoundedCornerShape(12.dp))
.background(colors.surfaceAlt)
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(12.dp))
) {
if (member.imageUrl == null) {
Box(
modifier = Modifier
.fillMaxSize()
.background(colors.surfaceAlt.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = null,
tint = colors.textMutedStrong
private fun SeriesCastMemberUiModel.toMediaCastMember() = MediaCastMember(
name = name,
role = role,
imageUrl = imageUrl
)
}
} else {
AsyncImage(
model = member.imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
Text(
text = member.name,
color = colors.textPrimary,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = member.role,
color = colors.textMutedStrong,
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun PlayButton(size: Dp, modifier: Modifier = Modifier) {
val colors = rememberSeriesColors()
Box(
modifier = modifier
.size(size)
.shadow(24.dp, CircleShape)
.clip(CircleShape)
.background(colors.primary)
.clickable { },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = colors.onPrimary,
modifier = Modifier.size(36.dp)
)
}
}

View File

@@ -0,0 +1,491 @@
package hu.bbara.purefin.common.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
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 coil3.compose.AsyncImage
import hu.bbara.purefin.app.content.episode.EpisodeColors
import hu.bbara.purefin.app.content.movie.MovieColors
import hu.bbara.purefin.app.content.series.SeriesColors
data class MediaDetailColors(
val primary: Color,
val onPrimary: Color,
val background: Color,
val surface: Color,
val surfaceAlt: Color,
val surfaceBorder: Color,
val textPrimary: Color,
val textSecondary: Color,
val textMuted: Color,
val textMutedStrong: Color
)
internal fun MovieColors.toMediaDetailColors() = MediaDetailColors(
primary = primary,
onPrimary = onPrimary,
background = background,
surface = surface,
surfaceAlt = surfaceAlt,
surfaceBorder = surfaceBorder,
textPrimary = textPrimary,
textSecondary = textSecondary,
textMuted = textMuted,
textMutedStrong = textMutedStrong
)
internal fun EpisodeColors.toMediaDetailColors() = MediaDetailColors(
primary = primary,
onPrimary = onPrimary,
background = background,
surface = surface,
surfaceAlt = surfaceAlt,
surfaceBorder = surfaceBorder,
textPrimary = textPrimary,
textSecondary = textSecondary,
textMuted = textMuted,
textMutedStrong = textMutedStrong
)
internal fun SeriesColors.toMediaDetailColors() = MediaDetailColors(
primary = primary,
onPrimary = onPrimary,
background = background,
surface = surface,
surfaceAlt = surfaceAlt,
surfaceBorder = surfaceBorder,
textPrimary = textPrimary,
textSecondary = textSecondary,
textMuted = textMuted,
textMutedStrong = textMutedStrong
)
@Composable
fun MediaGhostIconButton(
colors: MediaDetailColors,
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(40.dp)
.clip(CircleShape)
.background(colors.background.copy(alpha = 0.4f))
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = colors.textPrimary
)
}
}
@Composable
fun MediaHero(
imageUrl: String,
colors: MediaDetailColors,
height: Dp,
isWide: Boolean,
modifier: Modifier = Modifier,
showPlayButton: Boolean = false,
playButtonSize: Dp = 80.dp,
onPlayClick: (() -> Unit)? = null,
horizontalGradientOnWide: Boolean = true
) {
Box(
modifier = modifier
.height(height)
.background(colors.background)
) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
colors.background.copy(alpha = 0.4f),
colors.background
)
)
)
)
if (horizontalGradientOnWide && isWide) {
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.horizontalGradient(
colors = listOf(
Color.Transparent,
colors.background.copy(alpha = 0.8f)
)
)
)
)
}
if (showPlayButton && onPlayClick != null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
MediaPlayButton(
colors = colors,
size = playButtonSize,
onClick = onPlayClick
)
}
}
}
}
@Composable
fun MediaMetaChip(
colors: MediaDetailColors,
text: String,
background: Color = colors.surfaceAlt,
border: Color = Color.Transparent,
textColor: Color = colors.textSecondary,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.height(28.dp)
.wrapContentHeight(Alignment.CenterVertically)
.clip(RoundedCornerShape(6.dp))
.background(background)
.border(width = 1.dp, color = border, shape = RoundedCornerShape(6.dp))
.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = textColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
@Composable
fun MediaPlaybackSettings(
colors: MediaDetailColors,
audioTrack: String,
subtitles: String,
headerIcon: ImageVector = Icons.Outlined.Tune,
audioIcon: ImageVector = Icons.Outlined.VolumeUp,
subtitleIcon: ImageVector = Icons.Outlined.ClosedCaption,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surfaceAlt)
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(16.dp))
.padding(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = headerIcon,
contentDescription = null,
tint = colors.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Playback Settings",
color = colors.textMuted,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 2.sp
)
}
Spacer(modifier = Modifier.height(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
MediaSettingDropdown(
colors = colors,
label = "Audio Track",
value = audioTrack,
icon = audioIcon
)
MediaSettingDropdown(
colors = colors,
label = "Subtitles",
value = subtitles,
icon = subtitleIcon
)
}
}
}
@Composable
private fun MediaSettingDropdown(
colors: MediaDetailColors,
label: String,
value: String,
icon: ImageVector? = null
) {
Column {
Text(
text = label,
color = colors.textMutedStrong,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(start = 4.dp, bottom = 6.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(colors.surface)
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
Icon(imageVector = icon, contentDescription = null, tint = colors.textMutedStrong)
Spacer(modifier = Modifier.width(10.dp))
}
Text(text = value, color = colors.textPrimary, fontSize = 14.sp)
}
Icon(imageVector = Icons.Outlined.ExpandMore, contentDescription = null, tint = colors.textMutedStrong)
}
}
}
@Composable
fun MediaActionButtons(
colors: MediaDetailColors,
modifier: Modifier = Modifier,
height: Dp = 48.dp,
textSize: TextUnit = 14.sp,
watchlistIcon: ImageVector = Icons.Outlined.Add,
downloadIcon: ImageVector = Icons.Outlined.Download
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
MediaActionButton(
colors = colors,
text = "Watchlist",
icon = watchlistIcon,
modifier = Modifier.weight(1f),
height = height,
textSize = textSize
)
MediaActionButton(
colors = colors,
text = "Download",
icon = downloadIcon,
modifier = Modifier.weight(1f),
height = height,
textSize = textSize
)
}
}
@Composable
private fun MediaActionButton(
colors: MediaDetailColors,
text: String,
icon: ImageVector,
modifier: Modifier = Modifier,
height: Dp,
textSize: TextUnit
) {
Row(
modifier = modifier
.height(height)
.clip(RoundedCornerShape(12.dp))
.background(colors.surfaceAlt.copy(alpha = 0.6f))
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(12.dp))
.clickable { },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(imageVector = icon, contentDescription = null, tint = colors.textPrimary)
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, color = colors.textPrimary, fontSize = textSize, fontWeight = FontWeight.Bold)
}
}
data class MediaCastMember(
val name: String,
val role: String,
val imageUrl: String?
)
@Composable
fun MediaCastRow(
colors: MediaDetailColors,
cast: List<MediaCastMember>,
modifier: Modifier = Modifier,
cardWidth: Dp = 96.dp,
nameSize: TextUnit = 12.sp,
roleSize: TextUnit = 10.sp
) {
LazyRow(
modifier = modifier,
contentPadding = PaddingValues(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(cast) { member ->
Column(modifier = Modifier.width(cardWidth)) {
Box(
modifier = Modifier
.aspectRatio(4f / 5f)
.clip(RoundedCornerShape(12.dp))
.background(colors.surfaceAlt)
) {
if (member.imageUrl == null) {
Box(
modifier = Modifier
.fillMaxSize()
.background(colors.surfaceAlt.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = null,
tint = colors.textMutedStrong
)
}
} else {
AsyncImage(
model = member.imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = member.name,
color = colors.textPrimary,
fontSize = nameSize,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = member.role,
color = colors.textMutedStrong,
fontSize = roleSize,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
fun MediaPlayButton(
colors: MediaDetailColors,
size: Dp,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(size)
.shadow(24.dp, CircleShape)
.clip(CircleShape)
.background(colors.primary)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = colors.onPrimary,
modifier = Modifier.size(42.dp)
)
}
}
@Composable
fun MediaFloatingPlayButton(
colors: MediaDetailColors,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(56.dp)
.shadow(20.dp, CircleShape)
.clip(CircleShape)
.background(colors.primary)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = colors.onPrimary
)
}
}

View File

@@ -45,7 +45,6 @@ fun PosterCard(
modifier = Modifier
.width(144.dp)
) {
AsyncImage(
model = viewModel.getImageUrl(item.id, ImageType.PRIMARY),
contentDescription = null,