mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat(search): wire full-screen search with genre browsing
This commit is contained in:
@@ -13,61 +13,74 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.LocalFireDepartment
|
import androidx.compose.material.icons.outlined.Close
|
||||||
import androidx.compose.material.icons.outlined.Menu
|
import androidx.compose.material.icons.outlined.Menu
|
||||||
import androidx.compose.material.icons.outlined.Movie
|
|
||||||
import androidx.compose.material.icons.outlined.Person
|
import androidx.compose.material.icons.outlined.Person
|
||||||
import androidx.compose.material.icons.outlined.Search
|
import androidx.compose.material.icons.outlined.Search
|
||||||
import androidx.compose.material.icons.outlined.TrendingUp
|
|
||||||
import androidx.compose.material.icons.outlined.Tune
|
import androidx.compose.material.icons.outlined.Tune
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
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.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import hu.bbara.purefin.ui.common.image.PurefinLogo
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import hu.bbara.purefin.feature.search.SearchResult
|
||||||
|
import hu.bbara.purefin.feature.search.SearchViewModel
|
||||||
|
import hu.bbara.purefin.model.Genre
|
||||||
|
import hu.bbara.purefin.model.MediaKind
|
||||||
import hu.bbara.purefin.navigation.HOME_SEARCH_SHARED_BOUNDS_KEY
|
import hu.bbara.purefin.navigation.HOME_SEARCH_SHARED_BOUNDS_KEY
|
||||||
import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
|
import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
|
||||||
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
|
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
|
||||||
|
import hu.bbara.purefin.ui.common.image.PurefinLogo
|
||||||
import hu.bbara.purefin.ui.theme.AppTheme
|
import hu.bbara.purefin.ui.theme.AppTheme
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchFullScreen(
|
fun SearchFullScreen(
|
||||||
|
viewModel: SearchViewModel = hiltViewModel(),
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||||
val animatedVisibilityScope = LocalNavSharedAnimatedVisibilityScope.current
|
val animatedVisibilityScope = LocalNavSharedAnimatedVisibilityScope.current
|
||||||
|
val searchResults by viewModel.searchResult.collectAsStateWithLifecycle()
|
||||||
|
val genres by viewModel.genres.collectAsStateWithLifecycle()
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
var selectedGenreName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val sharedBoundsModifier = if (
|
val sharedBoundsModifier = if (
|
||||||
sharedTransitionScope != null &&
|
sharedTransitionScope != null &&
|
||||||
animatedVisibilityScope != null
|
animatedVisibilityScope != null
|
||||||
@@ -87,9 +100,41 @@ fun SearchFullScreen(
|
|||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SearchFullScreenContent(
|
||||||
|
query = query,
|
||||||
|
searchResults = searchResults,
|
||||||
|
genres = genres.sortedBy { it.name },
|
||||||
|
selectedGenreName = selectedGenreName,
|
||||||
|
onQueryChange = {
|
||||||
|
query = it
|
||||||
|
viewModel.search(it)
|
||||||
|
},
|
||||||
|
onSearch = { viewModel.search(query) },
|
||||||
|
onResultClick = viewModel::onSearchResultSelected,
|
||||||
|
onGenreSelected = { genre ->
|
||||||
|
selectedGenreName = genre.name.takeIf { it != selectedGenreName }
|
||||||
|
},
|
||||||
|
modifier = modifier.then(sharedBoundsModifier)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun SearchFullScreenContent(
|
||||||
|
query: String,
|
||||||
|
searchResults: List<SearchResult>,
|
||||||
|
genres: List<Genre>,
|
||||||
|
selectedGenreName: String?,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onSearch: () -> Unit,
|
||||||
|
onResultClick: (SearchResult) -> Unit,
|
||||||
|
onGenreSelected: (Genre) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.then(sharedBoundsModifier)
|
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(scheme.background)
|
.background(scheme.background)
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
@@ -99,46 +144,34 @@ fun SearchFullScreen(
|
|||||||
) {
|
) {
|
||||||
SearchHeader(modifier = Modifier.padding(top = 12.dp))
|
SearchHeader(modifier = Modifier.padding(top = 12.dp))
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
SearchField()
|
SearchField(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
onSearch = onSearch
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.height(30.dp))
|
Spacer(modifier = Modifier.height(30.dp))
|
||||||
SectionTitle(
|
if (query.isBlank()) {
|
||||||
text = "Trending Searches",
|
SectionTitle(text = "Browse Genres")
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.LocalFireDepartment,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = scheme.primary,
|
|
||||||
modifier = Modifier.size(26.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
FlowRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
previewTrendingSearches.forEach { title ->
|
|
||||||
TrendingSearchChip(title = title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(34.dp))
|
|
||||||
SectionTitle(text = "Browse Categories")
|
|
||||||
Spacer(modifier = Modifier.height(18.dp))
|
Spacer(modifier = Modifier.height(18.dp))
|
||||||
CategoryGrid(categories = previewCategories)
|
GenreChips(
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
genres = genres,
|
||||||
OutlinedButton(
|
selectedGenreName = selectedGenreName,
|
||||||
onClick = {},
|
onGenreSelected = onGenreSelected
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
border = BorderStroke(1.dp, scheme.primary.copy(alpha = 0.52f)),
|
|
||||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = scheme.primary),
|
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "View All Genres",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp)
|
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
SectionTitle(text = "Search Results")
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
if (searchResults.isEmpty()) {
|
||||||
|
SearchMessage(
|
||||||
|
title = "No matches",
|
||||||
|
body = "Try a different title or browse your libraries."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SearchResultsGrid(
|
||||||
|
results = searchResults,
|
||||||
|
onResultClick = onResultClick
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,46 +233,45 @@ private fun SearchHeader(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchField(
|
private fun SearchField(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onSearch: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
Surface(
|
TextField(
|
||||||
shape = RoundedCornerShape(24.dp),
|
value = query,
|
||||||
color = scheme.surfaceContainer,
|
onValueChange = onQueryChange,
|
||||||
border = BorderStroke(1.dp, scheme.outlineVariant.copy(alpha = 0.16f)),
|
singleLine = true,
|
||||||
modifier = modifier.fillMaxWidth()
|
textStyle = MaterialTheme.typography.titleMedium,
|
||||||
) {
|
placeholder = {
|
||||||
Row(
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
text = "Search movies, shows, genres...",
|
||||||
modifier = Modifier
|
style = MaterialTheme.typography.titleMedium,
|
||||||
.fillMaxWidth()
|
maxLines = 1,
|
||||||
.height(68.dp)
|
overflow = TextOverflow.Ellipsis
|
||||||
.padding(horizontal = 20.dp)
|
)
|
||||||
) {
|
},
|
||||||
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.Search,
|
imageVector = Icons.Outlined.Search,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = scheme.onSurfaceVariant,
|
tint = scheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(30.dp)
|
modifier = Modifier.size(30.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
},
|
||||||
Text(
|
trailingIcon = {
|
||||||
text = "Search movies, shows, genres...",
|
if (query.isNotBlank()) {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
color = scheme.onSurfaceVariant,
|
Icon(
|
||||||
maxLines = 1,
|
imageVector = Icons.Outlined.Close,
|
||||||
overflow = TextOverflow.Ellipsis,
|
contentDescription = "Clear search",
|
||||||
modifier = Modifier.weight(1f)
|
tint = scheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
}
|
||||||
Box(
|
} else {
|
||||||
modifier = Modifier
|
|
||||||
.height(34.dp)
|
|
||||||
.width(1.dp)
|
|
||||||
.background(scheme.outlineVariant.copy(alpha = 0.18f))
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(14.dp))
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.Tune,
|
imageVector = Icons.Outlined.Tune,
|
||||||
contentDescription = "Filter",
|
contentDescription = "Filter",
|
||||||
@@ -247,6 +279,54 @@ private fun SearchField(
|
|||||||
modifier = Modifier.size(30.dp)
|
modifier = Modifier.size(30.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = scheme.surfaceContainer,
|
||||||
|
unfocusedContainerColor = scheme.surfaceContainer,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onSearch = {
|
||||||
|
onSearch()
|
||||||
|
keyboardController?.hide()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(68.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchResultsGrid(
|
||||||
|
results: List<SearchResult>,
|
||||||
|
onResultClick: (SearchResult) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
results.chunked(2).forEach { rowItems ->
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
rowItems.forEach { item ->
|
||||||
|
SearchResultCard(
|
||||||
|
item = item,
|
||||||
|
onClick = { onResultClick(item) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (rowItems.size == 1) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,114 +353,30 @@ private fun SectionTitle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrendingSearchChip(
|
private fun GenreChips(
|
||||||
title: String,
|
genres: List<Genre>,
|
||||||
|
selectedGenreName: String?,
|
||||||
|
onGenreSelected: (Genre) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
Surface(
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
shape = RoundedCornerShape(24.dp),
|
modifier = modifier.fillMaxWidth()
|
||||||
color = scheme.surfaceContainer,
|
|
||||||
border = BorderStroke(1.dp, scheme.outlineVariant.copy(alpha = 0.14f)),
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
) {
|
||||||
Row(
|
genres.forEach { genre ->
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
FilterChip(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
selected = genre.name == selectedGenreName,
|
||||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 11.dp)
|
onClick = { onGenreSelected(genre) },
|
||||||
) {
|
label = {
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.TrendingUp,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = scheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = genre.name,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = scheme.onSurface,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CategoryGrid(
|
|
||||||
categories: List<SearchCategory>,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
modifier = modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
categories.chunked(2).forEach { rowItems ->
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
rowItems.forEach { category ->
|
|
||||||
CategoryCard(
|
|
||||||
category = category,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (rowItems.size == 1) {
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CategoryCard(
|
|
||||||
category: SearchCategory,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val shape = RoundedCornerShape(18.dp)
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
shape = shape,
|
|
||||||
color = scheme.surfaceContainer,
|
|
||||||
modifier = modifier
|
|
||||||
.clip(shape)
|
|
||||||
.aspectRatio(1.45f)
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
PurefinAsyncImage(
|
|
||||||
model = category.imageUrl,
|
|
||||||
contentDescription = category.name,
|
|
||||||
fallbackIcon = Icons.Outlined.Movie,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.matchParentSize()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Black.copy(alpha = 0.18f),
|
|
||||||
Color.Black.copy(alpha = 0.42f),
|
|
||||||
Color.Black.copy(alpha = 0.76f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = category.name,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = Color.White,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.padding(16.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,47 +385,43 @@ private fun CategoryCard(
|
|||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchFullScreenPreview() {
|
private fun SearchFullScreenPreview() {
|
||||||
|
var query by rememberSaveable { mutableStateOf("du") }
|
||||||
|
|
||||||
AppTheme {
|
AppTheme {
|
||||||
SearchFullScreen(modifier = Modifier.fillMaxSize())
|
SearchFullScreenContent(
|
||||||
|
query = query,
|
||||||
|
searchResults = previewSearchResults,
|
||||||
|
genres = previewGenres,
|
||||||
|
selectedGenreName = "Action",
|
||||||
|
onQueryChange = { query = it },
|
||||||
|
onSearch = {},
|
||||||
|
onResultClick = {},
|
||||||
|
onGenreSelected = {},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class SearchCategory(
|
private val previewGenres = listOf(
|
||||||
val name: String,
|
Genre(name = "Action"),
|
||||||
val imageUrl: String
|
Genre(name = "Adventure"),
|
||||||
|
Genre(name = "Comedy"),
|
||||||
|
Genre(name = "Fantasy"),
|
||||||
|
Genre(name = "Romance"),
|
||||||
|
Genre(name = "Sci-Fi")
|
||||||
)
|
)
|
||||||
|
|
||||||
private val previewTrendingSearches = listOf(
|
private val previewSearchResults = listOf(
|
||||||
"Dune",
|
SearchResult(
|
||||||
"Severance",
|
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||||||
"The Last of Us",
|
title = "Dune",
|
||||||
"Blade Runner",
|
posterUrl = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa",
|
||||||
"Foundation"
|
type = MediaKind.MOVIE
|
||||||
)
|
|
||||||
|
|
||||||
private val previewCategories = listOf(
|
|
||||||
SearchCategory(
|
|
||||||
name = "Action",
|
|
||||||
imageUrl = "https://images.unsplash.com/photo-1535016120720-40c646be5580"
|
|
||||||
),
|
),
|
||||||
SearchCategory(
|
SearchResult(
|
||||||
name = "Adventure",
|
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
|
||||||
imageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee"
|
title = "Dune: Part Two",
|
||||||
),
|
posterUrl = "https://images.unsplash.com/photo-1535016120720-40c646be5580",
|
||||||
SearchCategory(
|
type = MediaKind.MOVIE
|
||||||
name = "Comedy",
|
|
||||||
imageUrl = "https://images.unsplash.com/photo-1527224857830-43a7acc85260"
|
|
||||||
),
|
|
||||||
SearchCategory(
|
|
||||||
name = "Fantasy",
|
|
||||||
imageUrl = "https://images.unsplash.com/photo-1518709268805-4e9042af2176"
|
|
||||||
),
|
|
||||||
SearchCategory(
|
|
||||||
name = "Romance",
|
|
||||||
imageUrl = "https://images.unsplash.com/photo-1516589178581-6cd7833ae3b2"
|
|
||||||
),
|
|
||||||
SearchCategory(
|
|
||||||
name = "Sci-Fi",
|
|
||||||
imageUrl = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa"
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
5
core-model/src/main/java/hu/bbara/purefin/model/Genre.kt
Normal file
5
core-model/src/main/java/hu/bbara/purefin/model/Genre.kt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package hu.bbara.purefin.model
|
||||||
|
|
||||||
|
data class Genre(
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
@@ -3,6 +3,7 @@ package hu.bbara.purefin.data
|
|||||||
import hu.bbara.purefin.Offline
|
import hu.bbara.purefin.Offline
|
||||||
import hu.bbara.purefin.Online
|
import hu.bbara.purefin.Online
|
||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
|
import hu.bbara.purefin.model.Genre
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -10,7 +11,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
@@ -33,15 +34,19 @@ class CompositeMediaRepository @Inject constructor(
|
|||||||
|
|
||||||
override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
|
override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
|
||||||
.flatMapLatest { it.movies }
|
.flatMapLatest { it.movies }
|
||||||
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap())
|
.stateIn(scope, Eagerly, emptyMap())
|
||||||
|
|
||||||
override val series: StateFlow<Map<UUID, Series>> = activeRepository
|
override val series: StateFlow<Map<UUID, Series>> = activeRepository
|
||||||
.flatMapLatest { it.series }
|
.flatMapLatest { it.series }
|
||||||
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap())
|
.stateIn(scope, Eagerly, emptyMap())
|
||||||
|
|
||||||
override val episodes: StateFlow<Map<UUID, Episode>> = activeRepository
|
override val episodes: StateFlow<Map<UUID, Episode>> = activeRepository
|
||||||
.flatMapLatest { it.episodes }
|
.flatMapLatest { it.episodes }
|
||||||
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap())
|
.stateIn(scope, Eagerly, emptyMap())
|
||||||
|
|
||||||
|
override val genres: StateFlow<Set<Genre>> = activeRepository
|
||||||
|
.flatMapLatest { it.genres }
|
||||||
|
.stateIn(scope, Eagerly, emptySet())
|
||||||
|
|
||||||
override suspend fun getMovie(id: UUID): Flow<Movie?> {
|
override suspend fun getMovie(id: UUID): Flow<Movie?> {
|
||||||
return activeRepository
|
return activeRepository
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package hu.bbara.purefin.data
|
package hu.bbara.purefin.data
|
||||||
|
|
||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
|
import hu.bbara.purefin.model.Genre
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -12,6 +13,7 @@ interface MediaCatalogReader {
|
|||||||
val movies: StateFlow<Map<UUID, Movie>>
|
val movies: StateFlow<Map<UUID, Movie>>
|
||||||
val series: StateFlow<Map<UUID, Series>>
|
val series: StateFlow<Map<UUID, Series>>
|
||||||
val episodes: StateFlow<Map<UUID, Episode>>
|
val episodes: StateFlow<Map<UUID, Episode>>
|
||||||
|
val genres: StateFlow<Set<Genre>>
|
||||||
suspend fun getMovie(id: UUID): Flow<Movie?>
|
suspend fun getMovie(id: UUID): Flow<Movie?>
|
||||||
suspend fun getSeries(id: UUID): Flow<Series?>
|
suspend fun getSeries(id: UUID): Flow<Series?>
|
||||||
suspend fun getEpisode(id: UUID): Flow<Episode?>
|
suspend fun getEpisode(id: UUID): Flow<Episode?>
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import hu.bbara.purefin.data.MediaCatalogReader
|
import hu.bbara.purefin.data.MediaCatalogReader
|
||||||
import hu.bbara.purefin.data.UserSessionRepository
|
import hu.bbara.purefin.data.UserSessionRepository
|
||||||
|
import hu.bbara.purefin.image.ArtworkKind
|
||||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
import hu.bbara.purefin.image.ImageUrlBuilder
|
||||||
import java.util.UUID
|
import hu.bbara.purefin.model.MediaKind
|
||||||
|
import hu.bbara.purefin.navigation.MovieDto
|
||||||
|
import hu.bbara.purefin.navigation.NavigationManager
|
||||||
|
import hu.bbara.purefin.navigation.Route
|
||||||
|
import hu.bbara.purefin.navigation.SeriesDto
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -15,7 +20,7 @@ import kotlinx.coroutines.flow.debounce
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import hu.bbara.purefin.image.ArtworkKind
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -23,8 +28,11 @@ import javax.inject.Inject
|
|||||||
class SearchViewModel @Inject constructor(
|
class SearchViewModel @Inject constructor(
|
||||||
private val mediaCatalogReader: MediaCatalogReader,
|
private val mediaCatalogReader: MediaCatalogReader,
|
||||||
private val userSessionRepository: UserSessionRepository,
|
private val userSessionRepository: UserSessionRepository,
|
||||||
|
private val navigationManager: NavigationManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val genres = mediaCatalogReader.genres
|
||||||
|
|
||||||
private val _searchResult = MutableStateFlow<List<SearchResult>>(emptyList())
|
private val _searchResult = MutableStateFlow<List<SearchResult>>(emptyList())
|
||||||
val searchResult = _searchResult.asStateFlow()
|
val searchResult = _searchResult.asStateFlow()
|
||||||
|
|
||||||
@@ -54,6 +62,34 @@ class SearchViewModel @Inject constructor(
|
|||||||
this.query.value = query
|
this.query.value = query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onSearchResultSelected(searchResult: SearchResult) {
|
||||||
|
when (searchResult.type) {
|
||||||
|
MediaKind.MOVIE -> onMovieSelected(searchResult.id)
|
||||||
|
MediaKind.SERIES -> onSeriesSelected(searchResult.id)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onMovieSelected(movieId: UUID) {
|
||||||
|
navigationManager.navigate(
|
||||||
|
Route.MovieRoute(
|
||||||
|
MovieDto(
|
||||||
|
id = movieId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSeriesSelected(seriesId: UUID) {
|
||||||
|
navigationManager.navigate(
|
||||||
|
Route.SeriesRoute(
|
||||||
|
SeriesDto(
|
||||||
|
id = seriesId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun createImageUrl(id: UUID) : String {
|
private suspend fun createImageUrl(id: UUID) : String {
|
||||||
return ImageUrlBuilder.toImageUrl(userSessionRepository.serverUrl.first(), id,
|
return ImageUrlBuilder.toImageUrl(userSessionRepository.serverUrl.first(), id,
|
||||||
ArtworkKind.PRIMARY)
|
ArtworkKind.PRIMARY)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import hu.bbara.purefin.data.HomeRepository
|
|||||||
import hu.bbara.purefin.data.NetworkMonitor
|
import hu.bbara.purefin.data.NetworkMonitor
|
||||||
import hu.bbara.purefin.data.UserSessionRepository
|
import hu.bbara.purefin.data.UserSessionRepository
|
||||||
import hu.bbara.purefin.data.converter.toEpisode
|
import hu.bbara.purefin.data.converter.toEpisode
|
||||||
|
import hu.bbara.purefin.data.converter.toGenre
|
||||||
import hu.bbara.purefin.data.converter.toLibrary
|
import hu.bbara.purefin.data.converter.toLibrary
|
||||||
import hu.bbara.purefin.data.converter.toMovie
|
import hu.bbara.purefin.data.converter.toMovie
|
||||||
import hu.bbara.purefin.data.converter.toSeason
|
import hu.bbara.purefin.data.converter.toSeason
|
||||||
@@ -103,6 +104,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
loadContinueWatching()
|
loadContinueWatching()
|
||||||
loadNextUp()
|
loadNextUp()
|
||||||
loadLatestLibraryContent()
|
loadLatestLibraryContent()
|
||||||
|
loadGenres()
|
||||||
Log.d(TAG, "Home refresh successful")
|
Log.d(TAG, "Home refresh successful")
|
||||||
persistHomeCache()
|
persistHomeCache()
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
@@ -199,7 +201,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadLibraries() {
|
private suspend fun loadLibraries() {
|
||||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
Log.w(TAG, "Unable to load libraries", error)
|
Log.w(TAG, "Unable to load libraries", error)
|
||||||
@@ -221,7 +223,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
onlineMediaRepository.upsertSeries(series)
|
onlineMediaRepository.upsertSeries(series)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadLibrary(library: Library): Library {
|
private suspend fun loadLibrary(library: Library): Library {
|
||||||
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
|
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
Log.w(TAG, "Unable to load library ${library.id}", error)
|
Log.w(TAG, "Unable to load library ${library.id}", error)
|
||||||
@@ -237,7 +239,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadSuggestions() {
|
private suspend fun loadSuggestions() {
|
||||||
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
|
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
Log.w(TAG, "Unable to load suggestions", error)
|
Log.w(TAG, "Unable to load suggestions", error)
|
||||||
@@ -258,7 +260,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadContinueWatching() {
|
private suspend fun loadContinueWatching() {
|
||||||
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
|
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
Log.w(TAG, "Unable to load continue watching", error)
|
Log.w(TAG, "Unable to load continue watching", error)
|
||||||
@@ -279,7 +281,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadNextUp() {
|
private suspend fun loadNextUp() {
|
||||||
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
|
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
Log.w(TAG, "Unable to load next up", error)
|
Log.w(TAG, "Unable to load next up", error)
|
||||||
@@ -294,7 +296,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loadLatestLibraryContent() {
|
private suspend fun loadLatestLibraryContent() {
|
||||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
Log.w(TAG, "Unable to load latest library content", error)
|
Log.w(TAG, "Unable to load latest library content", error)
|
||||||
@@ -337,6 +339,12 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
latestLibraryContentState.value = latestLibraryContents
|
latestLibraryContentState.value = latestLibraryContents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun loadGenres() {
|
||||||
|
val baseItemDtos = jellyfinApiClient.getGenres()
|
||||||
|
val genres = baseItemDtos.map { it.toGenre() }
|
||||||
|
onlineMediaRepository.upsertGenres(genres.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun serverUrl(): String {
|
private suspend fun serverUrl(): String {
|
||||||
return userSessionRepository.serverUrl.first()
|
return userSessionRepository.serverUrl.first()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import hu.bbara.purefin.data.converter.toSeason
|
|||||||
import hu.bbara.purefin.data.converter.toSeries
|
import hu.bbara.purefin.data.converter.toSeries
|
||||||
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
|
||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
|
import hu.bbara.purefin.model.Genre
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -48,6 +49,9 @@ class InMemoryMediaRepository @Inject constructor(
|
|||||||
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
|
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
|
||||||
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
|
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
|
||||||
|
|
||||||
|
private val genresState = MutableStateFlow<Set<Genre>>(emptySet())
|
||||||
|
override var genres: StateFlow<Set<Genre>> = genresState.asStateFlow()
|
||||||
|
|
||||||
override suspend fun getMovie(id: UUID): Flow<Movie?> {
|
override suspend fun getMovie(id: UUID): Flow<Movie?> {
|
||||||
if (!moviesState.value.containsKey(id)) {
|
if (!moviesState.value.containsKey(id)) {
|
||||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
@@ -92,6 +96,10 @@ class InMemoryMediaRepository @Inject constructor(
|
|||||||
return episodesState.map { it[id] }
|
return episodesState.map { it[id] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun upsertGenres(genres: Set<Genre>) {
|
||||||
|
genresState.update { current -> current + genres }
|
||||||
|
}
|
||||||
|
|
||||||
fun upsertMovies(movies: List<Movie>) {
|
fun upsertMovies(movies: List<Movie>) {
|
||||||
moviesState.update { current -> current + movies.associateBy { it.id } }
|
moviesState.update { current -> current + movies.associateBy { it.id } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package hu.bbara.purefin.data.catalog
|
|||||||
import hu.bbara.purefin.data.MediaRepository
|
import hu.bbara.purefin.data.MediaRepository
|
||||||
import hu.bbara.purefin.data.offline.room.offline.OfflineRoomMediaLocalDataSource
|
import hu.bbara.purefin.data.offline.room.offline.OfflineRoomMediaLocalDataSource
|
||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
|
import hu.bbara.purefin.model.Genre
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
import hu.bbara.purefin.model.Series
|
import hu.bbara.purefin.model.Series
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -32,6 +34,8 @@ class OfflineMediaRepository @Inject constructor(
|
|||||||
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
|
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
|
||||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||||
|
|
||||||
|
override var genres: StateFlow<Set<Genre>> = MutableStateFlow(emptySet())
|
||||||
|
|
||||||
override suspend fun getMovie(id: UUID): Flow<Movie?> {
|
override suspend fun getMovie(id: UUID): Flow<Movie?> {
|
||||||
return movies.map { it[id] }
|
return movies.map { it[id] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package hu.bbara.purefin.data.converter
|
|||||||
import hu.bbara.purefin.image.ArtworkKind
|
import hu.bbara.purefin.image.ArtworkKind
|
||||||
import hu.bbara.purefin.image.ImageUrlBuilder
|
import hu.bbara.purefin.image.ImageUrlBuilder
|
||||||
import hu.bbara.purefin.model.Episode
|
import hu.bbara.purefin.model.Episode
|
||||||
|
import hu.bbara.purefin.model.Genre
|
||||||
import hu.bbara.purefin.model.Library
|
import hu.bbara.purefin.model.Library
|
||||||
import hu.bbara.purefin.model.LibraryKind
|
import hu.bbara.purefin.model.LibraryKind
|
||||||
import hu.bbara.purefin.model.Movie
|
import hu.bbara.purefin.model.Movie
|
||||||
@@ -112,6 +113,12 @@ fun BaseItemDto.toEpisode(serverUrl: String): Episode {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun BaseItemDto.toGenre() : Genre {
|
||||||
|
return Genre(
|
||||||
|
name = name ?: "Unknown"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
|
fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
|
||||||
if (date == null) {
|
if (date == null) {
|
||||||
return fallbackYear?.toString() ?: "—"
|
return fallbackYear?.toString() ?: "—"
|
||||||
|
|||||||
Reference in New Issue
Block a user