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:
2026-03-30 20:33:15 +02:00
parent 17eab1d8b5
commit 51fc17cb92
8 changed files with 1675 additions and 248 deletions

View File

@@ -50,6 +50,7 @@ fun AppScreen(
onMovieSelected = viewModel::onMovieSelected, onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected, onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected, onEpisodeSelected = viewModel::onEpisodeSelected,
onLibrarySelected = { library -> viewModel.onLibrarySelected(library.id, library.name) },
onProfileClick = {}, onProfileClick = {},
onSettingsClick = {}, onSettingsClick = {},
onLogoutClick = viewModel::logout, onLogoutClick = viewModel::logout,

View File

@@ -1,19 +1,31 @@
package hu.bbara.purefin.app.home package hu.bbara.purefin.app.home
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import hu.bbara.purefin.app.home.ui.HomeContent 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.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen( fun HomeScreen(
libraries: List<LibraryItem>, libraries: List<LibraryItem>,
@@ -25,6 +37,7 @@ fun HomeScreen(
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onLibrarySelected: (LibraryItem) -> Unit,
onProfileClick: () -> Unit, onProfileClick: () -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onLogoutClick: () -> Unit, onLogoutClick: () -> Unit,
@@ -32,12 +45,31 @@ fun HomeScreen(
onTabSelected: (Int) -> Unit, onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier 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( Scaffold(
modifier = modifier.fillMaxSize(), modifier = modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
topBar = { topBar = {
HomeTopBar( HomeDiscoveryTopBar(
title = "Watch now",
subtitle = subtitle,
scrollBehavior = scrollBehavior,
onSearchClick = { isSearchVisible = true },
onProfileClick = onProfileClick, onProfileClick = onProfileClick,
onSettingsClick = onSettingsClick, onSettingsClick = onSettingsClick,
onLogoutClick = onLogoutClick onLogoutClick = onLogoutClick
@@ -50,17 +82,37 @@ fun HomeScreen(
) )
} }
) { innerPadding -> ) { innerPadding ->
HomeContent( Box(modifier = Modifier.fillMaxSize()) {
libraries = libraries, HomeContent(
libraryContent = libraryContent, libraries = libraries,
continueWatching = continueWatching, libraryContent = libraryContent,
nextUp = nextUp, continueWatching = continueWatching,
isRefreshing = isRefreshing, nextUp = nextUp,
onRefresh = onRefresh, isRefreshing = isRefreshing,
onMovieSelected = onMovieSelected, onRefresh = onRefresh,
onSeriesSelected = onSeriesSelected, onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected, onSeriesSelected = onSeriesSelected,
modifier = Modifier.padding(innerPadding) 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)
)
}
} }
} }

View File

@@ -1,22 +1,33 @@
package hu.bbara.purefin.app.home.ui package hu.bbara.purefin.app.home.ui
import androidx.compose.foundation.background 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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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 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.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem 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.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import java.util.UUID as JavaUuid
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -30,53 +41,573 @@ fun HomeContent(
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onLibrarySelected: (LibraryItem) -> Unit,
onBrowseLibrariesClick: () -> Unit,
modifier: Modifier = Modifier 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( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
onRefresh = onRefresh, onRefresh = onRefresh,
modifier = modifier modifier = modifier.fillMaxSize()
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) { ) {
LazyColumn( Box(
modifier = Modifier.fillMaxSize() modifier = Modifier
) { .fillMaxSize()
item { .background(
Spacer(modifier = Modifier.height(8.dp)) 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()
)
}

View File

@@ -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
)

View File

@@ -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
)
}

View File

@@ -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
)
}
}
}

View File

@@ -1,5 +1,7 @@
package hu.bbara.purefin.app.home.ui 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.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.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import 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.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage 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.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind 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 @Composable
fun ContinueWatchingSection( fun ContinueWatchingSection(
@@ -49,110 +224,137 @@ fun ContinueWatchingSection(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
SectionHeader(
title = "Continue Watching", Column(
action = null verticalArrangement = Arrangement.spacedBy(4.dp),
) modifier = modifier
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items(items = items) { item -> SectionHeader(title = "Continue Watching")
ContinueWatchingCard( LazyRow(
item = item, contentPadding = PaddingValues(horizontal = 16.dp),
onMovieSelected = onMovieSelected, horizontalArrangement = Arrangement.spacedBy(16.dp),
onEpisodeSelected = onEpisodeSelected modifier = Modifier.fillMaxWidth()
) ) {
items(items = items, key = { item -> item.id }) { item ->
ContinueWatchingCard(
item = item,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected
)
}
} }
} }
} }
@Composable @Composable
fun ContinueWatchingCard( private fun ContinueWatchingCard(
item: ContinueWatchingItem, item: ContinueWatchingItem,
modifier: Modifier = Modifier,
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme 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 BaseItemKind.EPISODE -> listOf(
val density = LocalDensity.current "Episode ${item.episode?.index}",
item.episode?.runtime
).filterNotNull().filter { it.isNotBlank() }.joinToString("")
else -> ""
}
val imageUrl = when (item.type) { val imageUrl = when (item.type) {
BaseItemKind.MOVIE -> item.movie?.heroImageUrl BaseItemKind.MOVIE -> item.movie?.heroImageUrl
BaseItemKind.EPISODE -> item.episode?.heroImageUrl BaseItemKind.EPISODE -> item.episode?.heroImageUrl
else -> null else -> null
} }
val cardWidth = 280.dp Surface(
val cardHeight = cardWidth * 9 / 16 shape = RoundedCornerShape(26.dp),
color = scheme.surfaceContainerLow,
fun openItem(item: ContinueWatchingItem) { tonalElevation = 3.dp,
when (item.type) { modifier = modifier.width(320.dp)
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()
) { ) {
Box( Column(
modifier = Modifier modifier = Modifier
.width(cardWidth) .fillMaxWidth()
.aspectRatio(16f / 9f) .clickable {
.clip(RoundedCornerShape(16.dp)) when (item.type) {
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) BaseItemKind.MOVIE -> onMovieSelected(item.movie!!.id)
.background(scheme.surfaceVariant) BaseItemKind.EPISODE -> {
val episode = item.episode!!
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
else -> Unit
}
}
) { ) {
PurefinAsyncImage( Box(
model = imageRequest,
contentDescription = null,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.clickable { .aspectRatio(16f / 9f)
openItem(item) .background(scheme.surfaceVariant)
}, ) {
contentScale = ContentScale.Crop, if (imageUrl != null) {
) PurefinAsyncImage(
MediaProgressBar( model = imageUrl,
progress = item.progress.toFloat().nextUp().div(100), contentDescription = item.primaryText,
foregroundColor = scheme.onSurface, modifier = Modifier.fillMaxSize(),
backgroundColor = scheme.primary, contentScale = ContentScale.Crop
modifier = Modifier )
.align(Alignment.BottomStart) }
) Box(
} modifier = Modifier
Column(modifier = Modifier.padding(top = 12.dp)) { .matchParentSize()
Text( .background(
text = item.primaryText, Brush.verticalGradient(
color = scheme.onBackground, colors = listOf(
fontSize = 16.sp, Color.Transparent,
fontWeight = FontWeight.SemiBold, Color.Black.copy(alpha = 0.08f),
maxLines = 1, Color.Black.copy(alpha = 0.38f)
overflow = TextOverflow.Ellipsis )
) )
Text( )
text = item.secondaryText, )
color = scheme.onSurfaceVariant, ContentBadge(
fontSize = 13.sp, text = "Continue",
maxLines = 1, containerColor = scheme.surface.copy(alpha = 0.9f),
overflow = TextOverflow.Ellipsis 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 modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
SectionHeader(
title = "Next Up", Column(
action = null verticalArrangement = Arrangement.spacedBy(4.dp),
) modifier = modifier
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items( SectionHeader(title = "Next Up")
items = items, key = { it.id }) { item -> LazyRow(
NextUpCard( contentPadding = PaddingValues(horizontal = 16.dp),
item = item, horizontalArrangement = Arrangement.spacedBy(14.dp),
onEpisodeSelected = onEpisodeSelected modifier = Modifier.fillMaxWidth()
) ) {
items(items = items, key = { item -> item.id }) { item ->
NextUpCard(
item = item,
onEpisodeSelected = onEpisodeSelected
)
}
} }
} }
} }
@Composable @Composable
fun NextUpCard( private fun NextUpCard(
item: NextUpItem, item: NextUpItem,
modifier: Modifier = Modifier,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val context = LocalContext.current Surface(
val density = LocalDensity.current shape = RoundedCornerShape(24.dp),
color = scheme.surfaceContainer,
val imageUrl = item.episode.heroImageUrl tonalElevation = 2.dp,
modifier = modifier.width(256.dp)
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()
) { ) {
Box( Column(
modifier = Modifier modifier = Modifier
.width(cardWidth) .fillMaxWidth()
.aspectRatio(16f / 9f) .clickable {
.clip(RoundedCornerShape(16.dp)) onEpisodeSelected(
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) item.episode.seriesId,
.background(scheme.surfaceVariant) item.episode.seasonId,
item.episode.id
)
}
) { ) {
PurefinAsyncImage( Box(
model = imageRequest,
contentDescription = null,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.clickable { .aspectRatio(16f / 10f)
openItem(item) .background(scheme.surfaceVariant)
}, ) {
contentScale = ContentScale.Crop, PurefinAsyncImage(
) model = item.episode.heroImageUrl,
} contentDescription = item.primaryText,
Column(modifier = Modifier.padding(top = 12.dp)) { modifier = Modifier.fillMaxSize(),
Text( contentScale = ContentScale.Crop
text = item.primaryText, )
color = scheme.onBackground, Box(
fontSize = 16.sp, modifier = Modifier
fontWeight = FontWeight.SemiBold, .matchParentSize()
maxLines = 1, .background(
overflow = TextOverflow.Ellipsis Brush.verticalGradient(
) colors = listOf(
Text( Color.Transparent,
text = item.secondaryText, Color.Transparent,
color = scheme.onSurfaceVariant, Color.Black.copy(alpha = 0.26f)
fontSize = 13.sp, )
maxLines = 1, )
overflow = TextOverflow.Ellipsis )
) )
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 @Composable
fun LibraryPosterSection( fun LibraryPosterSection(
title: String, library: LibraryItem,
items: List<PosterItem>, items: List<PosterItem>,
action: String? = null, onLibrarySelected: (LibraryItem) -> Unit,
modifier: Modifier = Modifier,
onMovieSelected: (UUID) -> Unit, onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier,
) { ) {
SectionHeader( if (items.isEmpty()) return
title = title,
action = action Column(
) verticalArrangement = Arrangement.spacedBy(4.dp),
LazyRow( modifier = modifier
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items( SectionHeader(
items = items, key = { it.id }) { item -> title = library.name,
PosterCard( actionLabel = "See all",
item = item, onActionClick = { onLibrarySelected(library) }
onMovieSelected = onMovieSelected, )
onSeriesSelected = onSeriesSelected, LazyRow(
onEpisodeSelected = onEpisodeSelected 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 @Composable
fun SectionHeader( fun SectionHeader(
title: String, title: String,
action: String?, actionLabel: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onActionClick: () -> Unit = {} onActionClick: () -> Unit = {}
) { ) {
val scheme = MaterialTheme.colorScheme
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = title, text = title,
color = scheme.onBackground, style = MaterialTheme.typography.titleLarge,
fontSize = 20.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
if (action != null) { if (actionLabel != null) {
Text( TextButton(onClick = onActionClick) {
text = action, Text(text = actionLabel)
color = scheme.primary, Spacer(modifier = Modifier.width(4.dp))
fontSize = 14.sp, Icon(
fontWeight = FontWeight.SemiBold, imageVector = Icons.AutoMirrored.Outlined.ArrowForward,
modifier = Modifier.clickable { onActionClick() }) contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
} }
} }
} }
@Composable
fun HomeEmptyState(
onRefresh: () -> Unit,
onBrowseLibrariesClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Surface(
shape = RoundedCornerShape(30.dp),
color = scheme.surfaceContainerLow,
tonalElevation = 2.dp,
modifier = modifier.padding(horizontal = 16.dp)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(18.dp),
modifier = Modifier.padding(24.dp)
) {
ContentBadge(
text = "Home is warming up",
containerColor = scheme.primaryContainer,
contentColor = scheme.onPrimaryContainer
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Nothing is on deck yet",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "Pull to refresh for recent activity or jump into your libraries to start browsing.",
style = MaterialTheme.typography.bodyLarge,
color = scheme.onSurfaceVariant
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
FilledTonalButton(onClick = onRefresh) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text("Refresh")
}
OutlinedButton(onClick = onBrowseLibrariesClick) {
Icon(
imageVector = Icons.Outlined.Collections,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text("Browse libraries")
}
}
}
}
}
@Composable
private fun ContentBadge(
text: String,
containerColor: Color,
contentColor: Color,
modifier: Modifier = Modifier
) {
Surface(
color = containerColor,
shape = CircleShape,
modifier = modifier
) {
Text(
text = text,
color = contentColor,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}

View File

@@ -24,7 +24,7 @@ data class SearchResult(
id = series.id, id = series.id,
title = series.name, title = series.name,
posterUrl = imageUrl, posterUrl = imageUrl,
type = BaseItemKind.MOVIE type = BaseItemKind.SERIES
) )
} }
} }