Refactor home composables into subpackages

This commit is contained in:
2026-03-31 11:58:57 +02:00
parent 0e1682fd16
commit 4455640142
23 changed files with 1192 additions and 954 deletions

View File

@@ -6,7 +6,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import hu.bbara.purefin.app.home.ui.DownloadsContent
import hu.bbara.purefin.app.home.ui.downloads.DownloadsContent
@Composable
fun DownloadsScreen(

View File

@@ -15,12 +15,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import hu.bbara.purefin.app.home.ui.HomeContent
import hu.bbara.purefin.app.home.ui.HomeSearchOverlay
import hu.bbara.purefin.app.home.ui.HomeTopBar
import hu.bbara.purefin.app.home.ui.homePreviewContinueWatching
import hu.bbara.purefin.app.home.ui.homePreviewLibraries
import hu.bbara.purefin.app.home.ui.homePreviewLibraryContent
import hu.bbara.purefin.app.home.ui.homePreviewNextUp
import hu.bbara.purefin.app.home.ui.search.HomeSearchOverlay
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem

View File

@@ -6,9 +6,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import hu.bbara.purefin.app.home.ui.HomeNavItem
import hu.bbara.purefin.app.home.ui.DefaultTopBar
import hu.bbara.purefin.app.home.ui.LibrariesContent
import hu.bbara.purefin.app.home.ui.HomeNavItem
import hu.bbara.purefin.app.home.ui.libraries.LibrariesContent
@Composable
fun LibrariesScreen(

View File

@@ -15,6 +15,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.continuewatching.ContinueWatchingSection
import hu.bbara.purefin.app.home.ui.featured.HomeFeaturedSection
import hu.bbara.purefin.app.home.ui.library.LibraryPosterSection
import hu.bbara.purefin.app.home.ui.nextup.NextUpSection
import hu.bbara.purefin.app.home.ui.shared.HomeEmptyState
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series

View File

@@ -1,724 +0,0 @@
package hu.bbara.purefin.app.home.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
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.widthIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.graphics.Brush
import androidx.compose.ui.graphics.Color
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 hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.common.ui.components.UnwatchedEpisodeIndicator
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
fun HomeFeaturedSection(
items: List<FeaturedHomeItem>,
onOpenFeaturedItem: (FeaturedHomeItem) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
val pagerState = rememberPagerState(pageCount = { items.size })
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = modifier
) {
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(start = 16.dp, end = 56.dp),
pageSpacing = 16.dp,
modifier = Modifier.fillMaxWidth()
) { page ->
HomeFeaturedCard(
item = items[page],
onClick = { onOpenFeaturedItem(items[page]) }
)
}
if (items.size > 1) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp)
) {
repeat(items.size) { index ->
val selected = pagerState.currentPage == index
val indicatorWidth = animateDpAsState(
targetValue = if (selected) 22.dp else 8.dp
)
val indicatorColor = animateColorAsState(
targetValue = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
}
)
Box(
modifier = Modifier
.width(indicatorWidth.value)
.height(8.dp)
.clip(CircleShape)
.background(indicatorColor.value)
)
}
}
}
}
}
@Composable
private fun HomeFeaturedCard(
item: FeaturedHomeItem,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val description = item.description.trim()
Surface(
color = scheme.surfaceContainerLow,
shape = RoundedCornerShape(30.dp),
tonalElevation = 4.dp,
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(30.dp))
.clickable(onClick = onClick)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 11f)
) {
PurefinAsyncImage(
model = item.imageUrl,
contentDescription = item.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.08f),
Color.Black.copy(alpha = 0.18f),
Color.Black.copy(alpha = 0.72f)
)
)
)
)
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = item.title
)
Text(
text = item.title,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (item.metadata.isNotEmpty()) {
Text(
text = item.metadata.joinToString(""),
style = MaterialTheme.typography.labelLarge,
color = Color.White.copy(alpha = 0.88f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (description.isNotBlank()) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.88f),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 520.dp)
)
}
}
}
if (item.progress != null && item.progress > 0f) {
MediaProgressBar(
progress = item.progress.coerceIn(0f, 1f),
foregroundColor = scheme.primary,
backgroundColor = Color.White.copy(alpha = 0.26f),
modifier = Modifier.align(Alignment.BottomStart)
)
}
}
}
}
@Composable
fun ContinueWatchingSection(
items: List<ContinueWatchingItem>,
onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
) {
SectionHeader(title = "Continue Watching")
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
items(items = items, key = { item -> item.id }) { item ->
ContinueWatchingCard(
item = item,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected
)
}
}
}
}
@Composable
private fun ContinueWatchingCard(
item: ContinueWatchingItem,
onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val supportingText = when (item.type) {
BaseItemKind.MOVIE -> listOf(
item.movie?.year,
item.movie?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
BaseItemKind.EPISODE -> listOf(
"Episode ${item.episode?.index}",
item.episode?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
else -> ""
}
val imageUrl = when (item.type) {
BaseItemKind.MOVIE -> item.movie?.heroImageUrl
BaseItemKind.EPISODE -> item.episode?.heroImageUrl
else -> null
}
Surface(
shape = RoundedCornerShape(26.dp),
color = scheme.surfaceContainer,
tonalElevation = 3.dp,
modifier = modifier.width(320.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
BaseItemKind.EPISODE -> {
val episode = item.episode!!
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
else -> Unit
}
}
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(scheme.surfaceContainer)
) {
if (imageUrl != null) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = item.primaryText,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.08f),
Color.Black.copy(alpha = 0.38f)
)
)
)
)
MediaProgressBar(
progress = (item.progress.toFloat() / 100f).coerceIn(0f, 1f),
foregroundColor = scheme.primary,
backgroundColor = Color.White.copy(alpha = 0.24f),
modifier = Modifier.align(Alignment.BottomStart)
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)
) {
Text(
text = item.primaryText,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (supportingText.isNotBlank()) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@Composable
fun NextUpSection(
items: List<NextUpItem>,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
) {
SectionHeader(title = "Next Up")
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth()
) {
items(items = items, key = { item -> item.id }) { item ->
NextUpCard(
item = item,
onEpisodeSelected = onEpisodeSelected
)
}
}
}
}
@Composable
private fun NextUpCard(
item: NextUpItem,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Surface(
shape = RoundedCornerShape(24.dp),
color = scheme.surfaceContainer,
tonalElevation = 2.dp,
modifier = modifier.width(256.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
onEpisodeSelected(
item.episode.seriesId, item.episode.seasonId, item.episode.id
)
}
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 10f)
.background(scheme.surfaceVariant)
) {
PurefinAsyncImage(
model = item.episode.heroImageUrl,
contentDescription = item.primaryText,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Transparent,
Color.Black.copy(alpha = 0.26f)
)
)
)
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)
) {
Text(
text = item.primaryText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = listOf("Episode ${item.episode.index}", item.episode.runtime, item.secondaryText)
.filter { it.isNotBlank() }
.joinToString(""),
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
fun LibraryPosterSection(
library: LibraryItem,
items: List<PosterItem>,
onLibrarySelected: (LibraryItem) -> Unit,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier,
) {
if (items.isEmpty()) return
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
) {
SectionHeader(
title = library.name,
actionLabel = "See all",
onActionClick = { onLibrarySelected(library) }
)
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth()
) {
items(items = items, key = { item -> item.id }) { item ->
HomeBrowseCard(
item = item,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected
)
}
}
}
}
@Composable
private fun HomeBrowseCard(
item: PosterItem,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val supportingText = when (item.type) {
BaseItemKind.MOVIE -> listOf(
item.movie?.year,
item.movie?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
BaseItemKind.SERIES -> item.series!!.let { series ->
if (series.seasonCount == 1) "1 season" else "${series.seasonCount} seasons"
}
BaseItemKind.EPISODE -> listOf(
"Episode ${item.episode?.index}",
item.episode?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
else -> ""
}
Surface(
shape = RoundedCornerShape(12.dp),
color = scheme.surfaceContainer,
modifier = modifier.width(188.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.id)
BaseItemKind.SERIES -> onSeriesSelected(item.id)
BaseItemKind.EPISODE -> {
val episode = item.episode!!
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
else -> Unit
}
}
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 10f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.border(
1.dp, scheme.outlineVariant.copy(alpha = 0.35f), RoundedCornerShape(18.dp)
)
.background(scheme.surfaceVariant)
) {
PurefinAsyncImage(
model = item.imageUrl,
contentDescription = item.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
when (item.type) {
BaseItemKind.MOVIE -> {
val movie = item.movie!!
WatchStateIndicator(
size = 28,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
watched = movie.watched,
started = (movie.progress ?: 0.0) > 0
)
}
BaseItemKind.EPISODE -> {
val episode = item.episode!!
WatchStateIndicator(
size = 28,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
watched = episode.watched,
started = (episode.progress ?: 0.0) > 0
)
}
BaseItemKind.SERIES -> {
UnwatchedEpisodeIndicator(
size = 28,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
unwatchedCount = item.series!!.unwatchedEpisodeCount
)
}
else -> Unit
}
}
Spacer(modifier = Modifier.height(10.dp))
Column(modifier = modifier.padding(12.dp)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (supportingText.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@Composable
fun SectionHeader(
title: String,
actionLabel: String? = null,
modifier: Modifier = Modifier,
onActionClick: () -> Unit = {}
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
if (actionLabel != null) {
TextButton(onClick = onActionClick) {
Text(text = actionLabel)
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowForward,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
}
}
}
@Composable
fun HomeEmptyState(
onRefresh: () -> Unit,
onBrowseLibrariesClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Surface(
shape = RoundedCornerShape(30.dp),
color = scheme.surfaceContainerLow,
tonalElevation = 2.dp,
modifier = modifier.padding(horizontal = 16.dp)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(18.dp),
modifier = Modifier.padding(24.dp)
) {
ContentBadge(
text = "Home is warming up",
containerColor = scheme.primaryContainer,
contentColor = scheme.onPrimaryContainer
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Nothing is on deck yet",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "Pull to refresh for recent activity or jump into your libraries to start browsing.",
style = MaterialTheme.typography.bodyLarge,
color = scheme.onSurfaceVariant
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
FilledTonalButton(onClick = onRefresh) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text("Refresh")
}
OutlinedButton(onClick = onBrowseLibrariesClick) {
Icon(
imageVector = Icons.Outlined.Collections,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text("Browse libraries")
}
}
}
}
}
@Composable
private fun ContentBadge(
text: String,
containerColor: Color,
contentColor: Color,
modifier: Modifier = Modifier
) {
Surface(
color = containerColor,
shape = CircleShape,
modifier = modifier
) {
Text(
text = text,
color = contentColor,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}

View File

@@ -0,0 +1,137 @@
package hu.bbara.purefin.app.home.ui.continuewatching
import androidx.compose.foundation.background
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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 hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
internal fun ContinueWatchingCard(
item: ContinueWatchingItem,
onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val supportingText = when (item.type) {
BaseItemKind.MOVIE -> listOf(
item.movie?.year,
item.movie?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
BaseItemKind.EPISODE -> listOf(
"Episode ${item.episode?.index}",
item.episode?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
else -> ""
}
val imageUrl = when (item.type) {
BaseItemKind.MOVIE -> item.movie?.heroImageUrl
BaseItemKind.EPISODE -> item.episode?.heroImageUrl
else -> null
}
Surface(
shape = RoundedCornerShape(26.dp),
color = scheme.surfaceContainer,
tonalElevation = 3.dp,
modifier = modifier.width(320.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
BaseItemKind.EPISODE -> {
val episode = item.episode!!
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
else -> Unit
}
}
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(scheme.surfaceContainer)
) {
if (imageUrl != null) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = item.primaryText,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.08f),
Color.Black.copy(alpha = 0.38f)
)
)
)
)
MediaProgressBar(
progress = (item.progress.toFloat() / 100f).coerceIn(0f, 1f),
foregroundColor = scheme.primary,
backgroundColor = Color.White.copy(alpha = 0.24f),
modifier = Modifier.align(Alignment.BottomStart)
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)
) {
Text(
text = item.primaryText,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (supportingText.isNotBlank()) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
package hu.bbara.purefin.app.home.ui.continuewatching
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.shared.SectionHeader
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import org.jellyfin.sdk.model.UUID
@Composable
fun ContinueWatchingSection(
items: List<ContinueWatchingItem>,
onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
) {
SectionHeader(title = "Continue Watching")
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
items(items = items, key = { item -> item.id }) { item ->
ContinueWatchingCard(
item = item,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected
)
}
}
}
}

View File

@@ -1,24 +1,16 @@
package hu.bbara.purefin.app.home.ui
package hu.bbara.purefin.app.home.ui.downloads
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
@@ -27,7 +19,6 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -35,94 +26,11 @@ 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.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.download.ActiveDownloadItem
import hu.bbara.purefin.feature.shared.download.DownloadsViewModel
@Composable
fun DownloadsContent(
modifier: Modifier = Modifier,
viewModel: DownloadsViewModel = hiltViewModel(),
) {
val downloads = viewModel.downloads.collectAsState(emptyList())
val activeDownloads = viewModel.activeDownloads.collectAsState()
val isEmpty = downloads.value.isEmpty() && activeDownloads.value.isEmpty()
if (isEmpty) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Outlined.Download,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "No downloads yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.background(MaterialTheme.colorScheme.background)
) {
if (activeDownloads.value.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
text = "Downloading",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onBackground,
)
}
items(
items = activeDownloads.value,
key = { it.contentId },
span = { GridItemSpan(maxLineSpan) }
) { item ->
DownloadingItemRow(
item = item,
onCancel = { viewModel.cancelDownload(it) }
)
}
if (downloads.value.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
text = "Downloaded",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
items(downloads.value) { item ->
PosterCard(
item = item,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = { _, _, _ -> },
)
}
}
}
@Composable
private fun DownloadingItemRow(
internal fun DownloadingItemRow(
item: ActiveDownloadItem,
onCancel: (String) -> Unit,
) {

View File

@@ -0,0 +1,107 @@
package hu.bbara.purefin.app.home.ui.downloads
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.feature.shared.download.DownloadsViewModel
@Composable
fun DownloadsContent(
modifier: Modifier = Modifier,
viewModel: DownloadsViewModel = hiltViewModel(),
) {
val downloads = viewModel.downloads.collectAsState(emptyList())
val activeDownloads = viewModel.activeDownloads.collectAsState()
val isEmpty = downloads.value.isEmpty() && activeDownloads.value.isEmpty()
if (isEmpty) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Outlined.Download,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "No downloads yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.background(MaterialTheme.colorScheme.background)
) {
if (activeDownloads.value.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
text = "Downloading",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onBackground,
)
}
items(
items = activeDownloads.value,
key = { it.contentId },
span = { GridItemSpan(maxLineSpan) }
) { item ->
DownloadingItemRow(
item = item,
onCancel = { viewModel.cancelDownload(it) }
)
}
if (downloads.value.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
text = "Downloaded",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
items(downloads.value) { item ->
PosterCard(
item = item,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = { _, _, _ -> },
)
}
}
}

View File

@@ -0,0 +1,122 @@
package hu.bbara.purefin.app.home.ui.featured
import androidx.compose.foundation.background
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.graphics.Brush
import androidx.compose.ui.graphics.Color
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 hu.bbara.purefin.app.home.ui.FeaturedHomeItem
import hu.bbara.purefin.common.ui.components.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
@Composable
internal fun HomeFeaturedCard(
item: FeaturedHomeItem,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val description = item.description.trim()
Surface(
color = scheme.surfaceContainerLow,
shape = RoundedCornerShape(30.dp),
tonalElevation = 4.dp,
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(30.dp))
.clickable(onClick = onClick)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 11f)
) {
PurefinAsyncImage(
model = item.imageUrl,
contentDescription = item.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.08f),
Color.Black.copy(alpha = 0.18f),
Color.Black.copy(alpha = 0.72f)
)
)
)
)
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = item.title
)
Text(
text = item.title,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (item.metadata.isNotEmpty()) {
Text(
text = item.metadata.joinToString(""),
style = MaterialTheme.typography.labelLarge,
color = Color.White.copy(alpha = 0.88f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (description.isNotBlank()) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.88f),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 520.dp)
)
}
}
}
if (item.progress != null && item.progress > 0f) {
MediaProgressBar(
progress = item.progress.coerceIn(0f, 1f),
foregroundColor = scheme.primary,
backgroundColor = Color.White.copy(alpha = 0.26f),
modifier = Modifier.align(Alignment.BottomStart)
)
}
}
}
}

View File

@@ -0,0 +1,80 @@
package hu.bbara.purefin.app.home.ui.featured
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.FeaturedHomeItem
@Composable
fun HomeFeaturedSection(
items: List<FeaturedHomeItem>,
onOpenFeaturedItem: (FeaturedHomeItem) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
val pagerState = rememberPagerState(pageCount = { items.size })
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = modifier
) {
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(start = 16.dp, end = 56.dp),
pageSpacing = 16.dp,
modifier = Modifier.fillMaxWidth()
) { page ->
HomeFeaturedCard(
item = items[page],
onClick = { onOpenFeaturedItem(items[page]) }
)
}
if (items.size > 1) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp)
) {
repeat(items.size) { index ->
val selected = pagerState.currentPage == index
val indicatorWidth = animateDpAsState(
targetValue = if (selected) 22.dp else 8.dp
)
val indicatorColor = animateColorAsState(
targetValue = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
}
)
Box(
modifier = Modifier
.width(indicatorWidth.value)
.height(8.dp)
.clip(CircleShape)
.background(indicatorColor.value)
)
}
}
}
}
}

View File

@@ -0,0 +1,36 @@
package hu.bbara.purefin.app.home.ui.libraries
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.HomeNavItem
@Composable
fun LibrariesContent(
items: List<HomeNavItem>,
onLibrarySelected: (HomeNavItem) -> Unit,
modifier: Modifier = Modifier,
) {
LazyVerticalGrid(
modifier = modifier,
columns = GridCells.Adaptive(minSize = 160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(items, key = { it.id }) { item ->
LibraryListItem(
item = item,
modifier = Modifier.clickable {
onLibrarySelected(item)
}
)
}
}
}

View File

@@ -1,14 +1,8 @@
package hu.bbara.purefin.app.home.ui
package hu.bbara.purefin.app.home.ui.libraries
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -17,32 +11,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.HomeNavItem
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
@Composable
fun LibrariesContent(
items: List<HomeNavItem>,
onLibrarySelected: (HomeNavItem) -> Unit,
modifier: Modifier = Modifier,
) {
LazyVerticalGrid(
modifier = modifier,
columns = GridCells.Adaptive(minSize = 160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(items, key = { it.id }) { item ->
LibraryListItem(
item = item,
modifier = Modifier.clickable {
onLibrarySelected(item)
}
)
}
}
}
@Composable
fun LibraryListItem(
item: HomeNavItem,

View File

@@ -0,0 +1,158 @@
package hu.bbara.purefin.app.home.ui.library
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.common.ui.components.UnwatchedEpisodeIndicator
import hu.bbara.purefin.common.ui.components.WatchStateIndicator
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
internal fun HomeBrowseCard(
item: PosterItem,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val supportingText = when (item.type) {
BaseItemKind.MOVIE -> listOf(
item.movie?.year,
item.movie?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
BaseItemKind.SERIES -> item.series!!.let { series ->
if (series.seasonCount == 1) "1 season" else "${series.seasonCount} seasons"
}
BaseItemKind.EPISODE -> listOf(
"Episode ${item.episode?.index}",
item.episode?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
else -> ""
}
Surface(
shape = RoundedCornerShape(12.dp),
color = scheme.surfaceContainer,
modifier = modifier.width(188.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
when (item.type) {
BaseItemKind.MOVIE -> onMovieSelected(item.id)
BaseItemKind.SERIES -> onSeriesSelected(item.id)
BaseItemKind.EPISODE -> {
val episode = item.episode!!
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
else -> Unit
}
}
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 10f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.border(
1.dp, scheme.outlineVariant.copy(alpha = 0.35f), RoundedCornerShape(18.dp)
)
.background(scheme.surfaceVariant)
) {
PurefinAsyncImage(
model = item.imageUrl,
contentDescription = item.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
when (item.type) {
BaseItemKind.MOVIE -> {
val movie = item.movie!!
WatchStateIndicator(
size = 28,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
watched = movie.watched,
started = (movie.progress ?: 0.0) > 0
)
}
BaseItemKind.EPISODE -> {
val episode = item.episode!!
WatchStateIndicator(
size = 28,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
watched = episode.watched,
started = (episode.progress ?: 0.0) > 0
)
}
BaseItemKind.SERIES -> {
UnwatchedEpisodeIndicator(
size = 28,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
unwatchedCount = item.series!!.unwatchedEpisodeCount
)
}
else -> Unit
}
}
Spacer(modifier = Modifier.height(10.dp))
Column(modifier = modifier.padding(12.dp)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (supportingText.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}

View File

@@ -0,0 +1,53 @@
package hu.bbara.purefin.app.home.ui.library
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.shared.SectionHeader
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
@Composable
fun LibraryPosterSection(
library: LibraryItem,
items: List<PosterItem>,
onLibrarySelected: (LibraryItem) -> Unit,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier,
) {
if (items.isEmpty()) return
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
) {
SectionHeader(
title = library.name,
actionLabel = "See all",
onActionClick = { onLibrarySelected(library) }
)
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth()
) {
items(items = items, key = { item -> item.id }) { item ->
HomeBrowseCard(
item = item,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected
)
}
}
}
}

View File

@@ -0,0 +1,101 @@
package hu.bbara.purefin.app.home.ui.nextup
import androidx.compose.foundation.background
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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 hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.home.NextUpItem
import org.jellyfin.sdk.model.UUID
@Composable
internal fun NextUpCard(
item: NextUpItem,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Surface(
shape = RoundedCornerShape(24.dp),
color = scheme.surfaceContainer,
tonalElevation = 2.dp,
modifier = modifier.width(256.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
onEpisodeSelected(
item.episode.seriesId, item.episode.seasonId, item.episode.id
)
}
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 10f)
.background(scheme.surfaceVariant)
) {
PurefinAsyncImage(
model = item.episode.heroImageUrl,
contentDescription = item.primaryText,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Transparent,
Color.Black.copy(alpha = 0.26f)
)
)
)
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)
) {
Text(
text = item.primaryText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = listOf("Episode ${item.episode.index}", item.episode.runtime, item.secondaryText)
.filter { it.isNotBlank() }
.joinToString(""),
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -0,0 +1,42 @@
package hu.bbara.purefin.app.home.ui.nextup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.app.home.ui.shared.SectionHeader
import hu.bbara.purefin.feature.shared.home.NextUpItem
import org.jellyfin.sdk.model.UUID
@Composable
fun NextUpSection(
items: List<NextUpItem>,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
) {
SectionHeader(title = "Next Up")
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth()
) {
items(items = items, key = { item -> item.id }) { item ->
NextUpCard(
item = item,
onEpisodeSelected = onEpisodeSelected
)
}
}
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.home.ui
package hu.bbara.purefin.app.home.ui.search
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
@@ -9,12 +9,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
@@ -24,7 +22,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -35,15 +32,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.core.model.SearchResult
import hu.bbara.purefin.feature.shared.search.SearchViewModel
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -164,94 +155,3 @@ fun HomeSearchOverlay(
}
}
}
@Composable
private fun SearchMessage(
title: String,
body: String,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 32.dp)
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 1.dp,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
) {
androidx.compose.foundation.layout.Column(
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun HomeSearchResultCard(
item: SearchResult,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Surface(
shape = RoundedCornerShape(22.dp),
color = scheme.surfaceContainer,
tonalElevation = 2.dp,
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(22.dp))
.clickable(onClick = onClick)
) {
androidx.compose.foundation.layout.Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(12.dp)
) {
PurefinAsyncImage(
model = item.posterUrl,
contentDescription = item.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(18.dp))
)
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = when (item.type) {
BaseItemKind.MOVIE -> "Movie"
BaseItemKind.SERIES -> "Series"
else -> "Title"
},
style = MaterialTheme.typography.labelMedium,
color = scheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,72 @@
package hu.bbara.purefin.app.home.ui.search
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.core.model.SearchResult
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
internal fun HomeSearchResultCard(
item: SearchResult,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Surface(
shape = RoundedCornerShape(22.dp),
color = scheme.surfaceContainer,
tonalElevation = 2.dp,
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(22.dp))
.clickable(onClick = onClick)
) {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(12.dp)
) {
PurefinAsyncImage(
model = item.posterUrl,
contentDescription = item.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(18.dp))
)
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = when (item.type) {
BaseItemKind.MOVIE -> "Movie"
BaseItemKind.SERIES -> "Series"
else -> "Title"
},
style = MaterialTheme.typography.labelMedium,
color = scheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,56 @@
package hu.bbara.purefin.app.home.ui.search
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
internal fun SearchMessage(
title: String,
body: String,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 32.dp)
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 1.dp,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}

View File

@@ -0,0 +1,34 @@
package hu.bbara.purefin.app.home.ui.shared
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
internal fun ContentBadge(
text: String,
containerColor: Color,
contentColor: Color,
modifier: Modifier = Modifier
) {
Surface(
color = containerColor,
shape = CircleShape,
modifier = modifier
) {
Text(
text = text,
color = contentColor,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}

View File

@@ -0,0 +1,83 @@
package hu.bbara.purefin.app.home.ui.shared
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun HomeEmptyState(
onRefresh: () -> Unit,
onBrowseLibrariesClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Surface(
shape = RoundedCornerShape(30.dp),
color = scheme.surfaceContainerLow,
tonalElevation = 2.dp,
modifier = modifier.padding(horizontal = 16.dp)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(18.dp),
modifier = Modifier.padding(24.dp)
) {
ContentBadge(
text = "Home is warming up",
containerColor = scheme.primaryContainer,
contentColor = scheme.onPrimaryContainer
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Nothing is on deck yet",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "Pull to refresh for recent activity or jump into your libraries to start browsing.",
style = MaterialTheme.typography.bodyLarge,
color = scheme.onSurfaceVariant
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
FilledTonalButton(onClick = onRefresh) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text("Refresh")
}
OutlinedButton(onClick = onBrowseLibrariesClick) {
Icon(
imageVector = Icons.Outlined.Collections,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text("Browse libraries")
}
}
}
}
}

View File

@@ -0,0 +1,53 @@
package hu.bbara.purefin.app.home.ui.shared
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun SectionHeader(
title: String,
actionLabel: String? = null,
modifier: Modifier = Modifier,
onActionClick: () -> Unit = {}
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
if (actionLabel != null) {
TextButton(onClick = onActionClick) {
Text(text = actionLabel)
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowForward,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
}
}
}