diff --git a/app/src/main/java/hu/bbara/purefin/app/home/AppScreen.kt b/app/src/main/java/hu/bbara/purefin/app/home/AppScreen.kt index 48ffbc0..4736242 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/AppScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/AppScreen.kt @@ -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, diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeScreen.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeScreen.kt index 255bef0..06d8c6b 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomeScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeScreen.kt @@ -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, @@ -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) + ) + } } } diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt index 4997ecd..cd87483 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt @@ -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, + nextUp: List, + visibleLibraries: List, + libraryContent: Map> +): List { + 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 { + 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> { + 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 { + 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 { + 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() + ) +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDiscoveryModels.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDiscoveryModels.kt new file mode 100644 index 0000000..ac40b89 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDiscoveryModels.kt @@ -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, + val imageUrl: String, + val ctaLabel: String, + val progress: Float? = null, + val destination: HomeDestination +) diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDiscoveryTopBar.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDiscoveryTopBar.kt new file mode 100644 index 0000000..5e1a287 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDiscoveryTopBar.kt @@ -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 + ) +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSearchOverlay.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSearchOverlay.kt new file mode 100644 index 0000000..6a9f895 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSearchOverlay.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt index 7ef4da0..a45e5a2 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt @@ -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, + 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, - 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) + ) + } +} diff --git a/core/model/src/main/java/hu/bbara/purefin/core/model/SearchResult.kt b/core/model/src/main/java/hu/bbara/purefin/core/model/SearchResult.kt index e4209bb..efeabef 100644 --- a/core/model/src/main/java/hu/bbara/purefin/core/model/SearchResult.kt +++ b/core/model/src/main/java/hu/bbara/purefin/core/model/SearchResult.kt @@ -24,7 +24,7 @@ data class SearchResult( id = series.id, title = series.name, posterUrl = imageUrl, - type = BaseItemKind.MOVIE + type = BaseItemKind.SERIES ) } }