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 android.content.Intent
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.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.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.width import androidx.compose.foundation.verticalScroll
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.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.ArrowBack
import androidx.compose.material.icons.outlined.Cast 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.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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.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.platform.LocalContext
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.unit.Dp import androidx.compose.ui.unit.Dp
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
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 import hu.bbara.purefin.player.PlayerActivity
@Composable @Composable
@@ -63,100 +49,44 @@ internal fun EpisodeTopBar(
viewModel: EpisodeScreenViewModel = hiltViewModel(), viewModel: EpisodeScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = rememberEpisodeColors().toMediaDetailColors()
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
GhostIconButton( MediaGhostIconButton(
onClick = { viewModel.onBack() }, colors = colors,
icon = Icons.Outlined.ArrowBack, icon = Icons.Outlined.ArrowBack,
contentDescription = "Back" contentDescription = "Back",
onClick = { viewModel.onBack() }
) )
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast") MediaGhostIconButton(colors = colors, icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More") 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 @Composable
internal fun EpisodeHero( internal fun EpisodeHero(
episode: EpisodeUiModel, episode: EpisodeUiModel,
height: Dp, height: Dp,
isWide: Boolean, isWide: Boolean,
onPlayClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = rememberEpisodeColors() val colors = rememberEpisodeColors().toMediaDetailColors()
Box( MediaHero(
modifier = modifier imageUrl = episode.heroImageUrl,
.height(height) colors = colors,
.background(colors.background) height = height,
) { isWide = isWide,
AsyncImage( modifier = modifier,
model = episode.heroImageUrl, showPlayButton = true,
contentDescription = null, playButtonSize = if (isWide) 96.dp else 80.dp,
modifier = Modifier.fillMaxSize(), onPlayClick = onPlayClick
contentScale = ContentScale.Crop
) )
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) @OptIn(ExperimentalLayoutApi::class)
@@ -165,7 +95,7 @@ internal fun EpisodeDetails(
episode: EpisodeUiModel, episode: EpisodeUiModel,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = rememberEpisodeColors() val colors = rememberEpisodeColors().toMediaDetailColors()
Column(modifier = modifier) { Column(modifier = modifier) {
Text( Text(
text = episode.title, text = episode.title,
@@ -179,10 +109,11 @@ internal fun EpisodeDetails(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
MetaChip(text = episode.releaseDate) MediaMetaChip(colors = colors, text = episode.releaseDate)
MetaChip(text = episode.rating) MediaMetaChip(colors = colors, text = episode.rating)
MetaChip(text = episode.runtime) MediaMetaChip(colors = colors, text = episode.runtime)
MetaChip( MediaMetaChip(
colors = colors,
text = episode.format, text = episode.format,
background = colors.primary.copy(alpha = 0.2f), background = colors.primary.copy(alpha = 0.2f),
border = colors.primary.copy(alpha = 0.3f), border = colors.primary.copy(alpha = 0.3f),
@@ -191,7 +122,11 @@ internal fun EpisodeDetails(
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
PlaybackSettings(episode = episode) MediaPlaybackSettings(
colors = colors,
audioTrack = episode.audioTrack,
subtitles = episode.subtitles
)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
@@ -209,7 +144,7 @@ internal fun EpisodeDetails(
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
ActionButtons() MediaActionButtons(colors = colors)
Spacer(modifier = Modifier.height(28.dp)) Spacer(modifier = Modifier.height(28.dp))
Text( Text(
@@ -219,269 +154,108 @@ internal fun EpisodeDetails(
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
CastRow(cast = episode.cast) MediaCastRow(
} colors = colors,
} cast = episode.cast.map { it.toMediaCastMember() }
@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
) )
} }
} }
@Composable @Composable
private fun PlaybackSettings(episode: EpisodeUiModel) { fun EpisodeCard(
val colors = rememberEpisodeColors() episode: EpisodeUiModel,
Column( modifier: Modifier = Modifier,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surfaceAlt)
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(16.dp))
.padding(20.dp)
) { ) {
Row( val colors = rememberEpisodeColors().toMediaDetailColors()
verticalAlignment = Alignment.CenterVertically val context = LocalContext.current
) { val playAction = remember(episode.id) {
Icon( {
imageVector = Icons.Outlined.Tune, val intent = Intent(context, PlayerActivity::class.java)
contentDescription = null, intent.putExtra("MEDIA_ID", episode.id.toString())
tint = colors.primary context.startActivity(intent)
)
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
)
}
} }
} }
@Composable BoxWithConstraints(
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(
modifier = modifier 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() .fillMaxSize()
.background(colors.surfaceAlt.copy(alpha = 0.6f)), .background(colors.background)
contentAlignment = Alignment.Center
) { ) {
Icon( val isWide = maxWidth >= 900.dp
imageVector = Icons.Outlined.Person, val contentPadding = if (isWide) 32.dp else 20.dp
contentDescription = null,
tint = colors.textMutedStrong 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 { } else {
AsyncImage( Column(
model = member.imageUrl, modifier = Modifier
contentDescription = null, .fillMaxSize()
modifier = Modifier.fillMaxSize(), .verticalScroll(rememberScrollState())
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()
) { ) {
val colors = rememberEpisodeColors() EpisodeHero(
val context = LocalContext.current episode = episode,
val episodeItem = viewModel.episode.collectAsState() height = 400.dp,
isWide = false,
Box( onPlayClick = playAction,
modifier = modifier modifier = Modifier.fillMaxWidth()
.size(size) )
.shadow(24.dp, CircleShape) EpisodeDetails(
.clip(CircleShape) episode = episode,
.background(colors.primary) modifier = Modifier
.clickable { .fillMaxWidth()
val intent = Intent(context, PlayerActivity::class.java) .padding(horizontal = contentPadding)
intent.putExtra("MEDIA_ID", episodeItem.value!!.id.toString()) .offset(y = (-48).dp)
context.startActivity(intent) .padding(bottom = 96.dp)
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = colors.onPrimary,
modifier = Modifier.size(42.dp)
) )
} }
} }
@Composable EpisodeTopBar(
internal fun FloatingPlayButton(modifier: Modifier = Modifier) { modifier = Modifier
val colors = rememberEpisodeColors() .fillMaxWidth()
Box( .padding(horizontal = 16.dp, vertical = 16.dp)
modifier = modifier )
.size(56.dp)
.shadow(20.dp, CircleShape) if (!isWide) {
.clip(CircleShape) MediaFloatingPlayButton(
.background(colors.primary), colors = colors,
contentAlignment = Alignment.Center onClick = playAction,
) { modifier = Modifier
Icon( .align(Alignment.BottomEnd)
imageVector = Icons.Filled.PlayArrow, .padding(20.dp)
contentDescription = "Play",
tint = colors.onPrimary
) )
} }
} }
}
}
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 android.content.Intent
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.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.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.width import androidx.compose.foundation.verticalScroll
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.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.ArrowBack
import androidx.compose.material.icons.outlined.Cast 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.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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.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.platform.LocalContext
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.unit.Dp import androidx.compose.ui.unit.Dp
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
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 import hu.bbara.purefin.player.PlayerActivity
@Composable @Composable
@@ -63,99 +49,44 @@ internal fun MovieTopBar(
viewModel: MovieScreenViewModel = hiltViewModel(), viewModel: MovieScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = rememberMovieColors().toMediaDetailColors()
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
GhostIconButton( MediaGhostIconButton(
onClick = { viewModel.onBack() }, colors = colors,
icon = Icons.Outlined.ArrowBack, icon = Icons.Outlined.ArrowBack,
contentDescription = "Back" contentDescription = "Back",
onClick = { viewModel.onBack() }
) )
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast") MediaGhostIconButton(colors = colors, icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More") 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 @Composable
internal fun MovieHero( internal fun MovieHero(
movie: MovieUiModel, movie: MovieUiModel,
height: Dp, height: Dp,
isWide: Boolean, isWide: Boolean,
onPlayClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = rememberMovieColors() val colors = rememberMovieColors().toMediaDetailColors()
Box( MediaHero(
modifier = modifier imageUrl = movie.heroImageUrl,
.height(height) colors = colors,
.background(colors.background) height = height,
) { isWide = isWide,
AsyncImage( modifier = modifier,
model = movie.heroImageUrl, showPlayButton = true,
contentDescription = null, playButtonSize = if (isWide) 96.dp else 80.dp,
modifier = Modifier.fillMaxSize(), onPlayClick = onPlayClick
contentScale = ContentScale.Crop
) )
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) @OptIn(ExperimentalLayoutApi::class)
@@ -164,7 +95,7 @@ internal fun MovieDetails(
movie: MovieUiModel, movie: MovieUiModel,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = rememberMovieColors() val colors = rememberMovieColors().toMediaDetailColors()
Column(modifier = modifier) { Column(modifier = modifier) {
Text( Text(
text = movie.title, text = movie.title,
@@ -178,10 +109,11 @@ internal fun MovieDetails(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
MetaChip(text = movie.year) MediaMetaChip(colors = colors, text = movie.year)
MetaChip(text = movie.rating) MediaMetaChip(colors = colors, text = movie.rating)
MetaChip(text = movie.runtime) MediaMetaChip(colors = colors, text = movie.runtime)
MetaChip( MediaMetaChip(
colors = colors,
text = movie.format, text = movie.format,
background = colors.primary.copy(alpha = 0.2f), background = colors.primary.copy(alpha = 0.2f),
border = colors.primary.copy(alpha = 0.3f), border = colors.primary.copy(alpha = 0.3f),
@@ -190,7 +122,11 @@ internal fun MovieDetails(
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
PlaybackSettings(movie = movie) MediaPlaybackSettings(
colors = colors,
audioTrack = movie.audioTrack,
subtitles = movie.subtitles
)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
@@ -208,7 +144,7 @@ internal fun MovieDetails(
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
ActionButtons() MediaActionButtons(colors = colors)
Spacer(modifier = Modifier.height(28.dp)) Spacer(modifier = Modifier.height(28.dp))
Text( Text(
@@ -218,269 +154,108 @@ internal fun MovieDetails(
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
CastRow(cast = movie.cast) MediaCastRow(
} colors = colors,
} cast = movie.cast.map { it.toMediaCastMember() }
@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
) )
} }
} }
@Composable @Composable
private fun PlaybackSettings(movie: MovieUiModel) { fun MovieCard(
val colors = rememberMovieColors() movie: MovieUiModel,
Column( modifier: Modifier = Modifier,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(colors.surfaceAlt)
.border(1.dp, colors.surfaceBorder, RoundedCornerShape(16.dp))
.padding(20.dp)
) { ) {
Row( val colors = rememberMovieColors().toMediaDetailColors()
verticalAlignment = Alignment.CenterVertically val context = LocalContext.current
) { val playAction = remember(movie.id) {
Icon( {
imageVector = Icons.Outlined.Tune, val intent = Intent(context, PlayerActivity::class.java)
contentDescription = null, intent.putExtra("MEDIA_ID", movie.id.toString())
tint = colors.primary context.startActivity(intent)
)
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
)
}
} }
} }
@Composable BoxWithConstraints(
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(
modifier = modifier 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() .fillMaxSize()
.background(colors.surfaceAlt.copy(alpha = 0.6f)), .background(colors.background)
contentAlignment = Alignment.Center
) { ) {
Icon( val isWide = maxWidth >= 900.dp
imageVector = Icons.Outlined.Person, val contentPadding = if (isWide) 32.dp else 20.dp
contentDescription = null,
tint = colors.textMutedStrong 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 { } else {
AsyncImage( Column(
model = member.imageUrl, modifier = Modifier
contentDescription = null, .fillMaxSize()
modifier = Modifier.fillMaxSize(), .verticalScroll(rememberScrollState())
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()
) { ) {
val colors = rememberMovieColors() MovieHero(
val context = LocalContext.current movie = movie,
val movieId = viewModel.movie.collectAsState() height = 400.dp,
isWide = false,
Box( onPlayClick = playAction,
modifier = modifier modifier = Modifier.fillMaxWidth()
.size(size) )
.shadow(24.dp, CircleShape) MovieDetails(
.clip(CircleShape) movie = movie,
.background(colors.primary) modifier = Modifier
.clickable { .fillMaxWidth()
val intent = Intent(context, PlayerActivity::class.java) .padding(horizontal = contentPadding)
intent.putExtra("MEDIA_ID", movieId.value!!.id.toString()) .offset(y = (-48).dp)
context.startActivity(intent) .padding(bottom = 96.dp)
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = colors.onPrimary,
modifier = Modifier.size(42.dp)
) )
} }
} }
@Composable MovieTopBar(
internal fun FloatingPlayButton(modifier: Modifier = Modifier) { modifier = Modifier
val colors = rememberMovieColors() .fillMaxWidth()
Box( .padding(horizontal = 16.dp, vertical = 16.dp)
modifier = modifier )
.size(56.dp)
.shadow(20.dp, CircleShape) if (!isWide) {
.clip(CircleShape) MediaFloatingPlayButton(
.background(colors.primary), colors = colors,
contentAlignment = Alignment.Center onClick = playAction,
) { modifier = Modifier
Icon( .align(Alignment.BottomEnd)
imageVector = Icons.Filled.PlayArrow, .padding(20.dp)
contentDescription = "Play",
tint = colors.onPrimary
) )
} }
} }
}
}
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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
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.filled.PlayArrow
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.Cast 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.MoreVert
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -40,10 +34,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
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.layout.ContentScale import androidx.compose.ui.layout.ContentScale
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
@@ -52,98 +43,68 @@ 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
import coil3.compose.AsyncImage 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 @Composable
internal fun SeriesTopBar( internal fun SeriesTopBar(
viewModel: SeriesViewModel = hiltViewModel(), viewModel: SeriesViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = rememberSeriesColors().toMediaDetailColors()
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
GhostIconButton( MediaGhostIconButton(
colors = colors,
onClick = { viewModel.onBack() }, onClick = { viewModel.onBack() },
icon = Icons.Outlined.ArrowBack, icon = Icons.Outlined.ArrowBack,
contentDescription = "Back") contentDescription = "Back")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast") MediaGhostIconButton(colors = colors, icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { })
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More") 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 @Composable
internal fun SeriesHero( internal fun SeriesHero(
imageUrl: String, imageUrl: String,
height: Dp, height: Dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = rememberSeriesColors() val colors = rememberSeriesColors().toMediaDetailColors()
Box( MediaHero(
modifier = modifier imageUrl = imageUrl,
.height(height) colors = colors,
.background(colors.background) height = height,
) { isWide = false,
AsyncImage( modifier = modifier,
model = imageUrl, showPlayButton = false,
contentDescription = null, horizontalGradientOnWide = false
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
)
)
)
)
}
} }
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
internal fun SeriesMetaChips(series: SeriesUiModel) { internal fun SeriesMetaChips(series: SeriesUiModel) {
val colors = rememberSeriesColors() val colors = rememberSeriesColors().toMediaDetailColors()
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
MetaChip(text = series.year) MediaMetaChip(colors = colors, text = series.year)
MetaChip(text = series.rating) MediaMetaChip(colors = colors, text = series.rating)
MetaChip(text = series.seasons) MediaMetaChip(colors = colors, text = series.seasons)
MetaChip( MediaMetaChip(
colors = colors,
text = series.format, text = series.format,
background = colors.primary.copy(alpha = 0.2f), background = colors.primary.copy(alpha = 0.2f),
border = colors.primary.copy(alpha = 0.3f), 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 @Composable
internal fun SeriesActionButtons(modifier: Modifier = Modifier) { internal fun SeriesActionButtons(modifier: Modifier = Modifier) {
Row( val colors = rememberSeriesColors().toMediaDetailColors()
modifier = modifier.fillMaxWidth(), MediaActionButtons(
horizontalArrangement = Arrangement.spacedBy(12.dp) colors = colors,
) { modifier = modifier,
ActionButton( height = 44.dp,
text = "Watchlist", textSize = 13.sp
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 = 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 @Composable
@@ -360,88 +260,19 @@ private fun EpisodeCard(
@Composable @Composable
internal fun CastRow(cast: List<SeriesCastMemberUiModel>, modifier: Modifier = Modifier) { internal fun CastRow(cast: List<SeriesCastMemberUiModel>, modifier: Modifier = Modifier) {
LazyRow( val colors = rememberSeriesColors().toMediaDetailColors()
MediaCastRow(
colors = colors,
cast = cast.map { it.toMediaCastMember() },
modifier = modifier, modifier = modifier,
contentPadding = PaddingValues(horizontal = 12.dp), cardWidth = 84.dp,
horizontalArrangement = Arrangement.spacedBy(16.dp) nameSize = 11.sp,
) { roleSize = 10.sp
items(cast) { member -> )
CastCard(member = member)
}
}
} }
@Composable private fun SeriesCastMemberUiModel.toMediaCastMember() = MediaCastMember(
private fun CastCard(member: SeriesCastMemberUiModel) { name = name,
val colors = rememberSeriesColors() role = role,
Column( imageUrl = imageUrl
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
) )
}
} 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 modifier = Modifier
.width(144.dp) .width(144.dp)
) { ) {
AsyncImage( AsyncImage(
model = viewModel.getImageUrl(item.id, ImageType.PRIMARY), model = viewModel.getImageUrl(item.id, ImageType.PRIMARY),
contentDescription = null, contentDescription = null,