mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
feat: redesign home screen with discovery-focused UI
Overhauls the home screen to provide a modern, media-focused discovery experience. Key changes include: - Introduced a featured content carousel at the top, highlighting continue watching items, next up episodes, and library additions. - Implemented a search overlay with a real-time search bar and grid results for movies and series. - Redesigned the top bar with a large collapsible title and subtitle that dynamically reflects content status. - Updated sections for "Continue Watching", "Next Up", and libraries with high-fidelity cards, progress indicators, and better typography. - Added a dedicated empty state with quick actions for refreshing or browsing libraries. - Improved navigation by adding library-specific click actions and search-to-detail transitions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,7 @@ fun AppScreen(
|
|||||||
onMovieSelected = viewModel::onMovieSelected,
|
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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package hu.bbara.purefin.app.home.ui
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
|
||||||
|
enum class FeaturedHomeSource {
|
||||||
|
CONTINUE_WATCHING,
|
||||||
|
NEXT_UP,
|
||||||
|
LIBRARY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class HomeDestinationKind {
|
||||||
|
MOVIE,
|
||||||
|
SERIES,
|
||||||
|
EPISODE
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HomeDestination(
|
||||||
|
val kind: HomeDestinationKind,
|
||||||
|
val id: UUID,
|
||||||
|
val seriesId: UUID? = null,
|
||||||
|
val seasonId: UUID? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class FeaturedHomeItem(
|
||||||
|
val id: UUID,
|
||||||
|
val source: FeaturedHomeSource,
|
||||||
|
val badge: String,
|
||||||
|
val title: String,
|
||||||
|
val supportingText: String,
|
||||||
|
val description: String,
|
||||||
|
val metadata: List<String>,
|
||||||
|
val imageUrl: String,
|
||||||
|
val ctaLabel: String,
|
||||||
|
val progress: Float? = null,
|
||||||
|
val destination: HomeDestination
|
||||||
|
)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package hu.bbara.purefin.app.home.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Person
|
||||||
|
import androidx.compose.material.icons.outlined.Search
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LargeTopAppBar
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HomeDiscoveryTopBar(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
onSearchClick: () -> Unit,
|
||||||
|
onProfileClick: () -> Unit,
|
||||||
|
onSettingsClick: () -> Unit,
|
||||||
|
onLogoutClick: () -> Unit,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
var isProfileMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LargeTopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = scheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onSearchClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Search,
|
||||||
|
contentDescription = "Search"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { isProfileMenuExpanded = true },
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
) {
|
||||||
|
HomeAvatar(
|
||||||
|
size = 40.dp,
|
||||||
|
borderWidth = 1.dp,
|
||||||
|
borderColor = scheme.outlineVariant,
|
||||||
|
backgroundColor = scheme.secondaryContainer,
|
||||||
|
icon = Icons.Outlined.Person,
|
||||||
|
iconTint = scheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = isProfileMenuExpanded,
|
||||||
|
onDismissRequest = { isProfileMenuExpanded = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Profile") },
|
||||||
|
onClick = {
|
||||||
|
isProfileMenuExpanded = false
|
||||||
|
onProfileClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Settings") },
|
||||||
|
onClick = {
|
||||||
|
isProfileMenuExpanded = false
|
||||||
|
onSettingsClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Logout") },
|
||||||
|
onClick = {
|
||||||
|
isProfileMenuExpanded = false
|
||||||
|
onLogoutClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.largeTopAppBarColors(
|
||||||
|
containerColor = scheme.background,
|
||||||
|
scrolledContainerColor = scheme.surface.copy(alpha = 0.96f),
|
||||||
|
navigationIconContentColor = scheme.onSurface,
|
||||||
|
actionIconContentColor = scheme.onSurface,
|
||||||
|
titleContentColor = scheme.onSurface
|
||||||
|
),
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package hu.bbara.purefin.app.home.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Close
|
||||||
|
import androidx.compose.material.icons.outlined.Search
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SearchBar
|
||||||
|
import androidx.compose.material3.SearchBarDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||||
|
import hu.bbara.purefin.core.model.SearchResult
|
||||||
|
import hu.bbara.purefin.feature.shared.search.SearchViewModel
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HomeSearchOverlay(
|
||||||
|
visible: Boolean,
|
||||||
|
topPadding: Dp,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onMovieSelected: (UUID) -> Unit,
|
||||||
|
onSeriesSelected: (UUID) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
searchViewModel: SearchViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
if (!visible) return
|
||||||
|
|
||||||
|
BackHandler(onBack = onDismiss)
|
||||||
|
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
val searchResults by searchViewModel.searchResult.collectAsState()
|
||||||
|
val dismissInteractionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.36f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = dismissInteractionSource,
|
||||||
|
indication = null,
|
||||||
|
onClick = onDismiss
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SearchBar(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = topPadding + 8.dp, start = 16.dp, end = 16.dp),
|
||||||
|
inputField = {
|
||||||
|
SearchBarDefaults.InputField(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = {
|
||||||
|
query = it
|
||||||
|
searchViewModel.search(it)
|
||||||
|
},
|
||||||
|
onSearch = { searchViewModel.search(query) },
|
||||||
|
expanded = true,
|
||||||
|
onExpandedChange = { expanded ->
|
||||||
|
if (!expanded) {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = { Text("Search movies and shows") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Search,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Close,
|
||||||
|
contentDescription = "Close search"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
expanded = true,
|
||||||
|
onExpandedChange = { expanded ->
|
||||||
|
if (!expanded) {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
query.isBlank() -> {
|
||||||
|
SearchMessage(
|
||||||
|
title = "Search your library",
|
||||||
|
body = "Find movies and shows by title."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
searchResults.isEmpty() -> {
|
||||||
|
SearchMessage(
|
||||||
|
title = "No matches",
|
||||||
|
body = "Try a different title or browse your libraries."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(minSize = 132.dp),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
|
||||||
|
) {
|
||||||
|
items(searchResults, key = { result -> "${result.type}:${result.id}" }) { item ->
|
||||||
|
HomeSearchResultCard(
|
||||||
|
item = item,
|
||||||
|
onClick = {
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.MOVIE -> onMovieSelected(item.id)
|
||||||
|
BaseItemKind.SERIES -> onSeriesSelected(item.id)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchMessage(
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 32.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
tonalElevation = 1.dp,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
androidx.compose.foundation.layout.Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HomeSearchResultCard(
|
||||||
|
item: SearchResult,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(22.dp),
|
||||||
|
color = scheme.surfaceContainer,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(22.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
) {
|
||||||
|
androidx.compose.foundation.layout.Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
) {
|
||||||
|
PurefinAsyncImage(
|
||||||
|
model = item.posterUrl,
|
||||||
|
contentDescription = item.title,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(180.dp)
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = item.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = when (item.type) {
|
||||||
|
BaseItemKind.MOVIE -> "Movie"
|
||||||
|
BaseItemKind.SERIES -> "Series"
|
||||||
|
else -> "Title"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = scheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package hu.bbara.purefin.app.home.ui
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user