mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
feat: redesign home screen with discovery-focused UI
Overhauls the home screen to provide a modern, media-focused discovery experience. Key changes include: - Introduced a featured content carousel at the top, highlighting continue watching items, next up episodes, and library additions. - Implemented a search overlay with a real-time search bar and grid results for movies and series. - Redesigned the top bar with a large collapsible title and subtitle that dynamically reflects content status. - Updated sections for "Continue Watching", "Next Up", and libraries with high-fidelity cards, progress indicators, and better typography. - Added a dedicated empty state with quick actions for refreshing or browsing libraries. - Improved navigation by adding library-specific click actions and search-to-detail transitions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,7 @@ fun AppScreen(
|
||||
onMovieSelected = viewModel::onMovieSelected,
|
||||
onSeriesSelected = viewModel::onSeriesSelected,
|
||||
onEpisodeSelected = viewModel::onEpisodeSelected,
|
||||
onLibrarySelected = { library -> viewModel.onLibrarySelected(library.id, library.name) },
|
||||
onProfileClick = {},
|
||||
onSettingsClick = {},
|
||||
onLogoutClick = viewModel::logout,
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
package hu.bbara.purefin.app.home
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import hu.bbara.purefin.app.home.ui.HomeContent
|
||||
import hu.bbara.purefin.app.home.ui.HomeTopBar
|
||||
import hu.bbara.purefin.app.home.ui.HomeDiscoveryTopBar
|
||||
import hu.bbara.purefin.app.home.ui.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
|
||||
import hu.bbara.purefin.feature.shared.home.PosterItem
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
libraries: List<LibraryItem>,
|
||||
@@ -25,6 +37,7 @@ fun HomeScreen(
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
onLibrarySelected: (LibraryItem) -> Unit,
|
||||
onProfileClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onLogoutClick: () -> Unit,
|
||||
@@ -32,12 +45,31 @@ fun HomeScreen(
|
||||
onTabSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
|
||||
rememberTopAppBarState()
|
||||
)
|
||||
var isSearchVisible by rememberSaveable { mutableStateOf(false) }
|
||||
val subtitle = remember(continueWatching, nextUp, libraries) {
|
||||
when {
|
||||
continueWatching.isNotEmpty() -> "Continue where you left off"
|
||||
nextUp.isNotEmpty() -> "Fresh episodes waiting for you"
|
||||
libraries.isNotEmpty() -> "Browse your latest additions"
|
||||
else -> "Pull to refresh or explore your libraries"
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
topBar = {
|
||||
HomeTopBar(
|
||||
HomeDiscoveryTopBar(
|
||||
title = "Watch now",
|
||||
subtitle = subtitle,
|
||||
scrollBehavior = scrollBehavior,
|
||||
onSearchClick = { isSearchVisible = true },
|
||||
onProfileClick = onProfileClick,
|
||||
onSettingsClick = onSettingsClick,
|
||||
onLogoutClick = onLogoutClick
|
||||
@@ -50,17 +82,37 @@ fun HomeScreen(
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
HomeContent(
|
||||
libraries = libraries,
|
||||
libraryContent = libraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
HomeContent(
|
||||
libraries = libraries,
|
||||
libraryContent = libraryContent,
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
onLibrarySelected = onLibrarySelected,
|
||||
onBrowseLibrariesClick = { onTabSelected(1) },
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
HomeSearchOverlay(
|
||||
visible = isSearchVisible,
|
||||
topPadding = innerPadding.calculateTopPadding(),
|
||||
onDismiss = { isSearchVisible = false },
|
||||
onMovieSelected = {
|
||||
isSearchVisible = false
|
||||
onMovieSelected(it)
|
||||
},
|
||||
onSeriesSelected = {
|
||||
isSearchVisible = false
|
||||
onSeriesSelected(it)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
package hu.bbara.purefin.app.home.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.core.model.Movie
|
||||
import hu.bbara.purefin.core.model.Series
|
||||
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 hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import java.util.UUID as JavaUuid
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -30,53 +41,573 @@ fun HomeContent(
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
onLibrarySelected: (LibraryItem) -> Unit,
|
||||
onBrowseLibrariesClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val visibleLibraries = remember(libraries, libraryContent) {
|
||||
libraries.filter { libraryContent[it.id].orEmpty().isNotEmpty() }
|
||||
}
|
||||
val featuredItems = remember(continueWatching, nextUp, visibleLibraries, libraryContent) {
|
||||
buildFeaturedItems(
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
visibleLibraries = visibleLibraries,
|
||||
libraryContent = libraryContent
|
||||
)
|
||||
}
|
||||
val featuredLead = featuredItems.firstOrNull()
|
||||
val filteredContinueWatching = remember(continueWatching, featuredLead) {
|
||||
if (featuredLead?.source == FeaturedHomeSource.CONTINUE_WATCHING) {
|
||||
continueWatching.filterNot { it.id == featuredLead.id }
|
||||
} else {
|
||||
continueWatching
|
||||
}
|
||||
}
|
||||
val filteredNextUp = remember(nextUp, featuredLead) {
|
||||
if (featuredLead?.source == FeaturedHomeSource.NEXT_UP) {
|
||||
nextUp.filterNot { it.id == featuredLead.id }
|
||||
} else {
|
||||
nextUp
|
||||
}
|
||||
}
|
||||
val hasContent = featuredItems.isNotEmpty() ||
|
||||
filteredContinueWatching.isNotEmpty() ||
|
||||
filteredNextUp.isNotEmpty() ||
|
||||
visibleLibraries.isNotEmpty()
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
scheme.primaryContainer.copy(alpha = 0.24f),
|
||||
scheme.surface.copy(alpha = 0.92f),
|
||||
scheme.background
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
if (featuredItems.isNotEmpty()) {
|
||||
item(key = "featured") {
|
||||
HomeFeaturedSection(
|
||||
items = featuredItems,
|
||||
onOpenFeaturedItem = { item ->
|
||||
openHomeDestination(
|
||||
destination = item.destination,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredContinueWatching.isNotEmpty()) {
|
||||
item(key = "continue-watching") {
|
||||
ContinueWatchingSection(
|
||||
items = filteredContinueWatching,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredNextUp.isNotEmpty()) {
|
||||
item(key = "next-up") {
|
||||
NextUpSection(
|
||||
items = filteredNextUp,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = visibleLibraries,
|
||||
key = { library -> library.id }
|
||||
) { library ->
|
||||
LibraryPosterSection(
|
||||
library = library,
|
||||
items = libraryContent[library.id].orEmpty(),
|
||||
onLibrarySelected = onLibrarySelected,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
item(key = "empty-state") {
|
||||
HomeEmptyState(
|
||||
onRefresh = onRefresh,
|
||||
onBrowseLibrariesClick = onBrowseLibrariesClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
ContinueWatchingSection(
|
||||
items = continueWatching,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
item {
|
||||
NextUpSection(
|
||||
items = nextUp,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
items(
|
||||
items = libraries.filter { libraryContent[it.id]?.isEmpty() != true },
|
||||
key = { it.id }
|
||||
) { item ->
|
||||
LibraryPosterSection(
|
||||
title = item.name,
|
||||
items = libraryContent[item.id] ?: emptyList(),
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildFeaturedItems(
|
||||
continueWatching: List<ContinueWatchingItem>,
|
||||
nextUp: List<NextUpItem>,
|
||||
visibleLibraries: List<LibraryItem>,
|
||||
libraryContent: Map<UUID, List<PosterItem>>
|
||||
): List<FeaturedHomeItem> {
|
||||
val candidates = buildList {
|
||||
addAll(continueWatching.map { it.toFeaturedHomeItem() })
|
||||
addAll(nextUp.map { it.toFeaturedHomeItem() })
|
||||
visibleLibraries.forEach { library ->
|
||||
libraryContent[library.id]
|
||||
.orEmpty()
|
||||
.firstOrNull()
|
||||
?.let { add(it.toFeaturedHomeItem(library)) }
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
.distinctBy { "${it.destination.kind}:${it.id}" }
|
||||
.take(5)
|
||||
}
|
||||
|
||||
private fun ContinueWatchingItem.toFeaturedHomeItem(): FeaturedHomeItem {
|
||||
return when (type) {
|
||||
BaseItemKind.MOVIE -> {
|
||||
val movie = movie!!
|
||||
FeaturedHomeItem(
|
||||
id = movie.id,
|
||||
source = FeaturedHomeSource.CONTINUE_WATCHING,
|
||||
badge = "Continue watching",
|
||||
title = movie.title,
|
||||
supportingText = listOf(movie.year, movie.runtime)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" • "),
|
||||
description = movie.synopsis,
|
||||
metadata = listOf(movie.year, movie.runtime, movie.rating, movie.format)
|
||||
.filter { it.isNotBlank() },
|
||||
imageUrl = movie.heroImageUrl,
|
||||
ctaLabel = "Continue",
|
||||
progress = progress.toFloat() / 100f,
|
||||
destination = HomeDestination(
|
||||
kind = HomeDestinationKind.MOVIE,
|
||||
id = movie.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = episode!!
|
||||
FeaturedHomeItem(
|
||||
id = episode.id,
|
||||
source = FeaturedHomeSource.CONTINUE_WATCHING,
|
||||
badge = "Continue watching",
|
||||
title = episode.title,
|
||||
supportingText = listOf("Episode ${episode.index}", episode.runtime)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" • "),
|
||||
description = episode.synopsis,
|
||||
metadata = listOf(episode.releaseDate, episode.runtime, episode.rating, episode.format)
|
||||
.filter { it.isNotBlank() },
|
||||
imageUrl = episode.heroImageUrl,
|
||||
ctaLabel = "Continue",
|
||||
progress = progress.toFloat() / 100f,
|
||||
destination = HomeDestination(
|
||||
kind = HomeDestinationKind.EPISODE,
|
||||
id = episode.id,
|
||||
seriesId = episode.seriesId,
|
||||
seasonId = episode.seasonId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Unsupported featured type: $type")
|
||||
}
|
||||
}
|
||||
|
||||
private fun NextUpItem.toFeaturedHomeItem(): FeaturedHomeItem {
|
||||
return FeaturedHomeItem(
|
||||
id = episode.id,
|
||||
source = FeaturedHomeSource.NEXT_UP,
|
||||
badge = "Next up",
|
||||
title = episode.title,
|
||||
supportingText = listOf("Episode ${episode.index}", episode.runtime)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" • "),
|
||||
description = episode.synopsis,
|
||||
metadata = listOf(episode.releaseDate, episode.runtime, episode.rating)
|
||||
.filter { it.isNotBlank() },
|
||||
imageUrl = episode.heroImageUrl,
|
||||
ctaLabel = "Up next",
|
||||
destination = HomeDestination(
|
||||
kind = HomeDestinationKind.EPISODE,
|
||||
id = episode.id,
|
||||
seriesId = episode.seriesId,
|
||||
seasonId = episode.seasonId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun PosterItem.toFeaturedHomeItem(library: LibraryItem): FeaturedHomeItem {
|
||||
return when (type) {
|
||||
BaseItemKind.MOVIE -> {
|
||||
val movie = movie!!
|
||||
FeaturedHomeItem(
|
||||
id = movie.id,
|
||||
source = FeaturedHomeSource.LIBRARY,
|
||||
badge = library.name,
|
||||
title = movie.title,
|
||||
supportingText = listOf(movie.year, movie.runtime)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" • "),
|
||||
description = movie.synopsis,
|
||||
metadata = listOf(movie.year, movie.runtime, movie.rating)
|
||||
.filter { it.isNotBlank() },
|
||||
imageUrl = movie.heroImageUrl,
|
||||
ctaLabel = "Open",
|
||||
destination = HomeDestination(
|
||||
kind = HomeDestinationKind.MOVIE,
|
||||
id = movie.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
BaseItemKind.SERIES -> {
|
||||
val series = series!!
|
||||
FeaturedHomeItem(
|
||||
id = series.id,
|
||||
source = FeaturedHomeSource.LIBRARY,
|
||||
badge = library.name,
|
||||
title = series.name,
|
||||
supportingText = when {
|
||||
series.unwatchedEpisodeCount > 0 ->
|
||||
"${series.unwatchedEpisodeCount} unwatched episodes"
|
||||
else -> "${series.seasonCount} seasons"
|
||||
},
|
||||
description = series.synopsis,
|
||||
metadata = listOf(series.year, "${series.seasonCount} seasons")
|
||||
.filter { it.isNotBlank() },
|
||||
imageUrl = series.heroImageUrl,
|
||||
ctaLabel = "Open",
|
||||
destination = HomeDestination(
|
||||
kind = HomeDestinationKind.SERIES,
|
||||
id = series.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = episode!!
|
||||
FeaturedHomeItem(
|
||||
id = episode.id,
|
||||
source = FeaturedHomeSource.LIBRARY,
|
||||
badge = library.name,
|
||||
title = episode.title,
|
||||
supportingText = listOf("Episode ${episode.index}", episode.runtime)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" • "),
|
||||
description = episode.synopsis,
|
||||
metadata = listOf(episode.releaseDate, episode.runtime, episode.rating)
|
||||
.filter { it.isNotBlank() },
|
||||
imageUrl = episode.heroImageUrl,
|
||||
ctaLabel = "Open",
|
||||
destination = HomeDestination(
|
||||
kind = HomeDestinationKind.EPISODE,
|
||||
id = episode.id,
|
||||
seriesId = episode.seriesId,
|
||||
seasonId = episode.seasonId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Unsupported featured type: $type")
|
||||
}
|
||||
}
|
||||
|
||||
private fun openHomeDestination(
|
||||
destination: HomeDestination,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
) {
|
||||
when (destination.kind) {
|
||||
HomeDestinationKind.MOVIE -> onMovieSelected(destination.id)
|
||||
HomeDestinationKind.SERIES -> onSeriesSelected(destination.id)
|
||||
HomeDestinationKind.EPISODE -> onEpisodeSelected(
|
||||
destination.seriesId ?: return,
|
||||
destination.seasonId ?: return,
|
||||
destination.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Home Full", showBackground = true, widthDp = 412, heightDp = 915)
|
||||
@Composable
|
||||
private fun HomeContentPreview() {
|
||||
AppTheme(darkTheme = true) {
|
||||
HomeContent(
|
||||
libraries = previewLibraries(),
|
||||
libraryContent = previewLibraryContent(),
|
||||
continueWatching = previewContinueWatching(),
|
||||
nextUp = previewNextUp(),
|
||||
isRefreshing = false,
|
||||
onRefresh = {},
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> },
|
||||
onLibrarySelected = {},
|
||||
onBrowseLibrariesClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Home Libraries Only", showBackground = true, widthDp = 412, heightDp = 915)
|
||||
@Composable
|
||||
private fun HomeLibrariesOnlyPreview() {
|
||||
AppTheme(darkTheme = true) {
|
||||
HomeContent(
|
||||
libraries = previewLibraries(),
|
||||
libraryContent = previewLibraryContent(),
|
||||
continueWatching = emptyList(),
|
||||
nextUp = emptyList(),
|
||||
isRefreshing = false,
|
||||
onRefresh = {},
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> },
|
||||
onLibrarySelected = {},
|
||||
onBrowseLibrariesClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Home Empty", showBackground = true, widthDp = 412, heightDp = 915)
|
||||
@Composable
|
||||
private fun HomeEmptyPreview() {
|
||||
AppTheme(darkTheme = false) {
|
||||
HomeContent(
|
||||
libraries = emptyList(),
|
||||
libraryContent = emptyMap(),
|
||||
continueWatching = emptyList(),
|
||||
nextUp = emptyList(),
|
||||
isRefreshing = false,
|
||||
onRefresh = {},
|
||||
onMovieSelected = {},
|
||||
onSeriesSelected = {},
|
||||
onEpisodeSelected = { _, _, _ -> },
|
||||
onLibrarySelected = {},
|
||||
onBrowseLibrariesClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun previewLibraries(): List<LibraryItem> {
|
||||
return listOf(
|
||||
LibraryItem(
|
||||
id = JavaUuid.fromString("11111111-1111-1111-1111-111111111111"),
|
||||
name = "Movies",
|
||||
type = CollectionType.MOVIES,
|
||||
posterUrl = "https://images.unsplash.com/photo-1517604931442-7e0c8ed2963c",
|
||||
isEmpty = false
|
||||
),
|
||||
LibraryItem(
|
||||
id = JavaUuid.fromString("22222222-2222-2222-2222-222222222222"),
|
||||
name = "Series",
|
||||
type = CollectionType.TVSHOWS,
|
||||
posterUrl = "https://images.unsplash.com/photo-1489599849927-2ee91cede3ba",
|
||||
isEmpty = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun previewLibraryContent(): Map<UUID, List<PosterItem>> {
|
||||
val movie = previewMovie(
|
||||
id = "33333333-3333-3333-3333-333333333333",
|
||||
title = "Blade Runner 2049",
|
||||
year = "2017",
|
||||
runtime = "2h 44m",
|
||||
rating = "16+",
|
||||
format = "Dolby Vision",
|
||||
synopsis = "A young blade runner uncovers a buried secret that pulls him toward a vanished legend.",
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1519608487953-e999c86e7455",
|
||||
progress = 42.0,
|
||||
watched = false
|
||||
)
|
||||
val secondMovie = previewMovie(
|
||||
id = "44444444-4444-4444-4444-444444444444",
|
||||
title = "Arrival",
|
||||
year = "2016",
|
||||
runtime = "1h 56m",
|
||||
rating = "12+",
|
||||
format = "4K",
|
||||
synopsis = "A linguist is recruited when mysterious spacecraft touch down around the world.",
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429",
|
||||
progress = null,
|
||||
watched = false
|
||||
)
|
||||
val series = previewSeries()
|
||||
val episode = previewEpisode(
|
||||
id = "66666666-6666-6666-6666-666666666666",
|
||||
title = "Signals",
|
||||
index = 2,
|
||||
releaseDate = "2025",
|
||||
runtime = "48m",
|
||||
rating = "16+",
|
||||
progress = 18.0,
|
||||
watched = false,
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1520034475321-cbe63696469a",
|
||||
synopsis = "Anomalies around the station point to a cover-up."
|
||||
)
|
||||
|
||||
return mapOf(
|
||||
previewLibraries()[0].id to listOf(
|
||||
PosterItem(type = BaseItemKind.MOVIE, movie = movie),
|
||||
PosterItem(type = BaseItemKind.MOVIE, movie = secondMovie)
|
||||
),
|
||||
previewLibraries()[1].id to listOf(
|
||||
PosterItem(type = BaseItemKind.SERIES, series = series),
|
||||
PosterItem(type = BaseItemKind.EPISODE, episode = episode)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun previewContinueWatching(): List<ContinueWatchingItem> {
|
||||
return listOf(
|
||||
ContinueWatchingItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
movie = previewMovie(
|
||||
id = "77777777-7777-7777-7777-777777777777",
|
||||
title = "Dune: Part Two",
|
||||
year = "2024",
|
||||
runtime = "2h 46m",
|
||||
rating = "13+",
|
||||
format = "IMAX",
|
||||
synopsis = "Paul Atreides unites with the Fremen while seeking justice and revenge.",
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa",
|
||||
progress = 58.0,
|
||||
watched = false
|
||||
)
|
||||
),
|
||||
ContinueWatchingItem(
|
||||
type = BaseItemKind.EPISODE,
|
||||
episode = previewEpisode(
|
||||
id = "88888888-8888-8888-8888-888888888888",
|
||||
title = "A Fresh Start",
|
||||
index = 1,
|
||||
releaseDate = "2025",
|
||||
runtime = "51m",
|
||||
rating = "16+",
|
||||
progress = 23.0,
|
||||
watched = false,
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1497032205916-ac775f0649ae",
|
||||
synopsis = "A fractured crew tries to reassemble after a year apart."
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun previewNextUp(): List<NextUpItem> {
|
||||
return listOf(
|
||||
NextUpItem(
|
||||
episode = previewEpisode(
|
||||
id = "99999999-9999-9999-9999-999999999999",
|
||||
title = "Return Window",
|
||||
index = 3,
|
||||
releaseDate = "2025",
|
||||
runtime = "54m",
|
||||
rating = "16+",
|
||||
progress = null,
|
||||
watched = false,
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
|
||||
synopsis = "A high-risk jump changes the rules of the mission."
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun previewMovie(
|
||||
id: String,
|
||||
title: String,
|
||||
year: String,
|
||||
runtime: String,
|
||||
rating: String,
|
||||
format: String,
|
||||
synopsis: String,
|
||||
heroImageUrl: String,
|
||||
progress: Double?,
|
||||
watched: Boolean
|
||||
): Movie {
|
||||
return Movie(
|
||||
id = JavaUuid.fromString(id),
|
||||
libraryId = JavaUuid.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
title = title,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
year = year,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
heroImageUrl = heroImageUrl,
|
||||
audioTrack = "English 5.1",
|
||||
subtitles = "English CC",
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun previewSeries(): Series {
|
||||
return Series(
|
||||
id = JavaUuid.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
||||
libraryId = JavaUuid.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"),
|
||||
name = "Orbital",
|
||||
synopsis = "A reluctant crew returns to a damaged station as political pressure mounts on Earth.",
|
||||
year = "2025",
|
||||
heroImageUrl = "https://images.unsplash.com/photo-1520034475321-cbe63696469a",
|
||||
unwatchedEpisodeCount = 4,
|
||||
seasonCount = 2,
|
||||
seasons = emptyList(),
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun previewEpisode(
|
||||
id: String,
|
||||
title: String,
|
||||
index: Int,
|
||||
releaseDate: String,
|
||||
runtime: String,
|
||||
rating: String,
|
||||
progress: Double?,
|
||||
watched: Boolean,
|
||||
heroImageUrl: String,
|
||||
synopsis: String
|
||||
): Episode {
|
||||
return Episode(
|
||||
id = JavaUuid.fromString(id),
|
||||
seriesId = JavaUuid.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"),
|
||||
seasonId = JavaUuid.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"),
|
||||
index = index,
|
||||
title = title,
|
||||
synopsis = synopsis,
|
||||
releaseDate = releaseDate,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
progress = progress,
|
||||
watched = watched,
|
||||
format = "4K",
|
||||
heroImageUrl = heroImageUrl,
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package hu.bbara.purefin.app.home.ui
|
||||
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
enum class FeaturedHomeSource {
|
||||
CONTINUE_WATCHING,
|
||||
NEXT_UP,
|
||||
LIBRARY
|
||||
}
|
||||
|
||||
enum class HomeDestinationKind {
|
||||
MOVIE,
|
||||
SERIES,
|
||||
EPISODE
|
||||
}
|
||||
|
||||
data class HomeDestination(
|
||||
val kind: HomeDestinationKind,
|
||||
val id: UUID,
|
||||
val seriesId: UUID? = null,
|
||||
val seasonId: UUID? = null
|
||||
)
|
||||
|
||||
data class FeaturedHomeItem(
|
||||
val id: UUID,
|
||||
val source: FeaturedHomeSource,
|
||||
val badge: String,
|
||||
val title: String,
|
||||
val supportingText: String,
|
||||
val description: String,
|
||||
val metadata: List<String>,
|
||||
val imageUrl: String,
|
||||
val ctaLabel: String,
|
||||
val progress: Float? = null,
|
||||
val destination: HomeDestination
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
package hu.bbara.purefin.app.home.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeDiscoveryTopBar(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onSearchClick: () -> Unit,
|
||||
onProfileClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onLogoutClick: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isProfileMenuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
LargeTopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = scheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onSearchClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Search,
|
||||
contentDescription = "Search"
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { isProfileMenuExpanded = true },
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape),
|
||||
) {
|
||||
HomeAvatar(
|
||||
size = 40.dp,
|
||||
borderWidth = 1.dp,
|
||||
borderColor = scheme.outlineVariant,
|
||||
backgroundColor = scheme.secondaryContainer,
|
||||
icon = Icons.Outlined.Person,
|
||||
iconTint = scheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = isProfileMenuExpanded,
|
||||
onDismissRequest = { isProfileMenuExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Profile") },
|
||||
onClick = {
|
||||
isProfileMenuExpanded = false
|
||||
onProfileClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Settings") },
|
||||
onClick = {
|
||||
isProfileMenuExpanded = false
|
||||
onSettingsClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Logout") },
|
||||
onClick = {
|
||||
isProfileMenuExpanded = false
|
||||
onLogoutClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.largeTopAppBarColors(
|
||||
containerColor = scheme.background,
|
||||
scrolledContainerColor = scheme.surface.copy(alpha = 0.96f),
|
||||
navigationIconContentColor = scheme.onSurface,
|
||||
actionIconContentColor = scheme.onSurface,
|
||||
titleContentColor = scheme.onSurface
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package hu.bbara.purefin.app.home.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
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
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeSearchOverlay(
|
||||
visible: Boolean,
|
||||
topPadding: Dp,
|
||||
onDismiss: () -> Unit,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
searchViewModel: SearchViewModel = hiltViewModel(),
|
||||
) {
|
||||
if (!visible) return
|
||||
|
||||
BackHandler(onBack = onDismiss)
|
||||
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
val searchResults by searchViewModel.searchResult.collectAsState()
|
||||
val dismissInteractionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.36f))
|
||||
.clickable(
|
||||
interactionSource = dismissInteractionSource,
|
||||
indication = null,
|
||||
onClick = onDismiss
|
||||
)
|
||||
)
|
||||
SearchBar(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(top = topPadding + 8.dp, start = 16.dp, end = 16.dp),
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
query = query,
|
||||
onQueryChange = {
|
||||
query = it
|
||||
searchViewModel.search(it)
|
||||
},
|
||||
onSearch = { searchViewModel.search(query) },
|
||||
expanded = true,
|
||||
onExpandedChange = { expanded ->
|
||||
if (!expanded) {
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
placeholder = { Text("Search movies and shows") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = "Close search"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
expanded = true,
|
||||
onExpandedChange = { expanded ->
|
||||
if (!expanded) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
) {
|
||||
when {
|
||||
query.isBlank() -> {
|
||||
SearchMessage(
|
||||
title = "Search your library",
|
||||
body = "Find movies and shows by title."
|
||||
)
|
||||
}
|
||||
|
||||
searchResults.isEmpty() -> {
|
||||
SearchMessage(
|
||||
title = "No matches",
|
||||
body = "Try a different title or browse your libraries."
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 132.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
items(searchResults, key = { result -> "${result.type}:${result.id}" }) { item ->
|
||||
HomeSearchResultCard(
|
||||
item = item,
|
||||
onClick = {
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> onMovieSelected(item.id)
|
||||
BaseItemKind.SERIES -> onSeriesSelected(item.id)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
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
|
||||
@@ -8,38 +10,211 @@ 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.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.filled.PlayArrow
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.request.ImageRequest
|
||||
import hu.bbara.purefin.common.ui.PosterCard
|
||||
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
|
||||
import kotlin.math.nextUp
|
||||
|
||||
@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)
|
||||
) {
|
||||
ContentBadge(
|
||||
text = item.badge,
|
||||
containerColor = scheme.surface.copy(alpha = 0.88f),
|
||||
contentColor = scheme.onSurface
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
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
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
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)
|
||||
)
|
||||
}
|
||||
FilledTonalButton(onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = item.ctaLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
@@ -49,110 +224,137 @@ fun ContinueWatchingSection(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
SectionHeader(
|
||||
title = "Continue Watching",
|
||||
action = null
|
||||
)
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier
|
||||
) {
|
||||
items(items = items) { item ->
|
||||
ContinueWatchingCard(
|
||||
item = item,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
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
|
||||
fun ContinueWatchingCard(
|
||||
private fun ContinueWatchingCard(
|
||||
item: ContinueWatchingItem,
|
||||
modifier: Modifier = Modifier,
|
||||
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(" • ")
|
||||
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
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
|
||||
}
|
||||
|
||||
val cardWidth = 280.dp
|
||||
val cardHeight = cardWidth * 9 / 16
|
||||
|
||||
fun openItem(item: ContinueWatchingItem) {
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = item.episode!!
|
||||
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val imageRequest = ImageRequest.Builder(context)
|
||||
.data(imageUrl)
|
||||
.size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() })
|
||||
.build()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(cardWidth)
|
||||
.wrapContentHeight()
|
||||
Surface(
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = scheme.surfaceContainerLow,
|
||||
tonalElevation = 3.dp,
|
||||
modifier = modifier.width(320.dp)
|
||||
) {
|
||||
Box(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(cardWidth)
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp))
|
||||
.background(scheme.surfaceVariant)
|
||||
.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
|
||||
}
|
||||
}
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = null,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
openItem(item)
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
MediaProgressBar(
|
||||
progress = item.progress.toFloat().nextUp().div(100),
|
||||
foregroundColor = scheme.onSurface,
|
||||
backgroundColor = scheme.primary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
text = item.primaryText,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = item.secondaryText,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.background(scheme.surfaceVariant)
|
||||
) {
|
||||
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)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
ContentBadge(
|
||||
text = "Continue",
|
||||
containerColor = scheme.surface.copy(alpha = 0.9f),
|
||||
contentColor = scheme.onSurface,
|
||||
modifier = Modifier.padding(14.dp)
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,122 +366,267 @@ fun NextUpSection(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
SectionHeader(
|
||||
title = "Next Up",
|
||||
action = null
|
||||
)
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier
|
||||
) {
|
||||
items(
|
||||
items = items, key = { it.id }) { item ->
|
||||
NextUpCard(
|
||||
item = item,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
)
|
||||
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
|
||||
fun NextUpCard(
|
||||
private fun NextUpCard(
|
||||
item: NextUpItem,
|
||||
modifier: Modifier = Modifier,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val imageUrl = item.episode.heroImageUrl
|
||||
|
||||
val cardWidth = 280.dp
|
||||
val cardHeight = cardWidth * 9 / 16
|
||||
|
||||
fun openItem(item: NextUpItem) {
|
||||
val episode = item.episode
|
||||
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
|
||||
}
|
||||
|
||||
val imageRequest = ImageRequest.Builder(context)
|
||||
.data(imageUrl)
|
||||
.size(with(density) { cardWidth.roundToPx() }, with(density) { cardHeight.roundToPx() })
|
||||
.build()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(cardWidth)
|
||||
.wrapContentHeight()
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = scheme.surfaceContainer,
|
||||
tonalElevation = 2.dp,
|
||||
modifier = modifier.width(256.dp)
|
||||
) {
|
||||
Box(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(cardWidth)
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp))
|
||||
.background(scheme.surfaceVariant)
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onEpisodeSelected(
|
||||
item.episode.seriesId,
|
||||
item.episode.seasonId,
|
||||
item.episode.id
|
||||
)
|
||||
}
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = null,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
openItem(item)
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
text = item.primaryText,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = item.secondaryText,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
.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)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
ContentBadge(
|
||||
text = "Up next",
|
||||
containerColor = scheme.secondaryContainer.copy(alpha = 0.9f),
|
||||
contentColor = scheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
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(
|
||||
title: String,
|
||||
library: LibraryItem,
|
||||
items: List<PosterItem>,
|
||||
action: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
onLibrarySelected: (LibraryItem) -> Unit,
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
SectionHeader(
|
||||
title = title,
|
||||
action = action
|
||||
)
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
if (items.isEmpty()) return
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier
|
||||
) {
|
||||
items(
|
||||
items = items, key = { it.id }) { item ->
|
||||
PosterCard(
|
||||
item = item,
|
||||
onMovieSelected = onMovieSelected,
|
||||
onSeriesSelected = onSeriesSelected,
|
||||
onEpisodeSelected = onEpisodeSelected
|
||||
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(24.dp),
|
||||
color = scheme.surfaceContainer,
|
||||
tonalElevation = 1.dp,
|
||||
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
|
||||
}
|
||||
}
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 10f)
|
||||
.clip(RoundedCornerShape(18.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))
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,32 +634,114 @@ fun LibraryPosterSection(
|
||||
@Composable
|
||||
fun SectionHeader(
|
||||
title: String,
|
||||
action: String?,
|
||||
actionLabel: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
onActionClick: () -> Unit = {}
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 20.sp,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (action != null) {
|
||||
Text(
|
||||
text = action,
|
||||
color = scheme.primary,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.clickable { onActionClick() })
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ data class SearchResult(
|
||||
id = series.id,
|
||||
title = series.name,
|
||||
posterUrl = imageUrl,
|
||||
type = BaseItemKind.MOVIE
|
||||
type = BaseItemKind.SERIES
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user