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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.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.Movie
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.TrendingUp
|
||||
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.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
import hu.bbara.purefin.ui.common.image.PurefinLogo
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
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.LocalNavSharedAnimatedVisibilityScope
|
||||
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
|
||||
import hu.bbara.purefin.ui.common.image.PurefinLogo
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun SearchFullScreen(
|
||||
viewModel: SearchViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val sharedTransitionScope = LocalSharedTransitionScope.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 (
|
||||
sharedTransitionScope != null &&
|
||||
animatedVisibilityScope != null
|
||||
@@ -87,9 +100,41 @@ fun SearchFullScreen(
|
||||
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(
|
||||
modifier = modifier
|
||||
.then(sharedBoundsModifier)
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.statusBarsPadding()
|
||||
@@ -99,46 +144,34 @@ fun SearchFullScreen(
|
||||
) {
|
||||
SearchHeader(modifier = Modifier.padding(top = 12.dp))
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
SearchField()
|
||||
SearchField(
|
||||
query = query,
|
||||
onQueryChange = onQueryChange,
|
||||
onSearch = onSearch
|
||||
)
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
SectionTitle(
|
||||
text = "Trending Searches",
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.LocalFireDepartment,
|
||||
contentDescription = null,
|
||||
tint = scheme.primary,
|
||||
modifier = Modifier.size(26.dp)
|
||||
if (query.isBlank()) {
|
||||
SectionTitle(text = "Browse Genres")
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
GenreChips(
|
||||
genres = genres,
|
||||
selectedGenreName = selectedGenreName,
|
||||
onGenreSelected = onGenreSelected
|
||||
)
|
||||
} 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
|
||||
)
|
||||
}
|
||||
)
|
||||
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))
|
||||
CategoryGrid(categories = previewCategories)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
OutlinedButton(
|
||||
onClick = {},
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,52 +233,99 @@ private fun SearchHeader(
|
||||
|
||||
@Composable
|
||||
private fun SearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onSearch: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = scheme.surfaceContainer,
|
||||
border = BorderStroke(1.dp, scheme.outlineVariant.copy(alpha = 0.16f)),
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(68.dp)
|
||||
.padding(horizontal = 20.dp)
|
||||
) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.titleMedium,
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "Search movies, shows, genres...",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Search,
|
||||
contentDescription = null,
|
||||
tint = scheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = "Search movies, shows, genres...",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = scheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(34.dp)
|
||||
.width(1.dp)
|
||||
.background(scheme.outlineVariant.copy(alpha = 0.18f))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Tune,
|
||||
contentDescription = "Filter",
|
||||
tint = scheme.primary,
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (query.isNotBlank()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = "Clear search",
|
||||
tint = scheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Tune,
|
||||
contentDescription = "Filter",
|
||||
tint = scheme.primary,
|
||||
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
|
||||
private fun TrendingSearchChip(
|
||||
title: String,
|
||||
private fun GenreChips(
|
||||
genres: List<Genre>,
|
||||
selectedGenreName: String?,
|
||||
onGenreSelected: (Genre) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = scheme.surfaceContainer,
|
||||
border = BorderStroke(1.dp, scheme.outlineVariant.copy(alpha = 0.14f)),
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 11.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.TrendingUp,
|
||||
contentDescription = null,
|
||||
tint = scheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = scheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryGrid(
|
||||
categories: List<SearchCategory>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.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)
|
||||
genres.forEach { genre ->
|
||||
FilterChip(
|
||||
selected = genre.name == selectedGenreName,
|
||||
onClick = { onGenreSelected(genre) },
|
||||
label = {
|
||||
Text(
|
||||
text = genre.name,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
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)
|
||||
@Composable
|
||||
private fun SearchFullScreenPreview() {
|
||||
var query by rememberSaveable { mutableStateOf("du") }
|
||||
|
||||
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(
|
||||
val name: String,
|
||||
val imageUrl: String
|
||||
private val previewGenres = listOf(
|
||||
Genre(name = "Action"),
|
||||
Genre(name = "Adventure"),
|
||||
Genre(name = "Comedy"),
|
||||
Genre(name = "Fantasy"),
|
||||
Genre(name = "Romance"),
|
||||
Genre(name = "Sci-Fi")
|
||||
)
|
||||
|
||||
private val previewTrendingSearches = listOf(
|
||||
"Dune",
|
||||
"Severance",
|
||||
"The Last of Us",
|
||||
"Blade Runner",
|
||||
"Foundation"
|
||||
)
|
||||
|
||||
private val previewCategories = listOf(
|
||||
SearchCategory(
|
||||
name = "Action",
|
||||
imageUrl = "https://images.unsplash.com/photo-1535016120720-40c646be5580"
|
||||
private val previewSearchResults = listOf(
|
||||
SearchResult(
|
||||
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||||
title = "Dune",
|
||||
posterUrl = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa",
|
||||
type = MediaKind.MOVIE
|
||||
),
|
||||
SearchCategory(
|
||||
name = "Adventure",
|
||||
imageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee"
|
||||
),
|
||||
SearchCategory(
|
||||
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"
|
||||
SearchResult(
|
||||
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
|
||||
title = "Dune: Part Two",
|
||||
posterUrl = "https://images.unsplash.com/photo-1535016120720-40c646be5580",
|
||||
type = MediaKind.MOVIE
|
||||
)
|
||||
)
|
||||
|
||||
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.Online
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Genre
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.model.Series
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -10,7 +11,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
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.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
@@ -33,15 +34,19 @@ class CompositeMediaRepository @Inject constructor(
|
||||
|
||||
override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
|
||||
.flatMapLatest { it.movies }
|
||||
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap())
|
||||
.stateIn(scope, Eagerly, emptyMap())
|
||||
|
||||
override val series: StateFlow<Map<UUID, Series>> = activeRepository
|
||||
.flatMapLatest { it.series }
|
||||
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap())
|
||||
.stateIn(scope, Eagerly, emptyMap())
|
||||
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> = activeRepository
|
||||
.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?> {
|
||||
return activeRepository
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package hu.bbara.purefin.data
|
||||
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Genre
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.model.Series
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -12,6 +13,7 @@ interface MediaCatalogReader {
|
||||
val movies: StateFlow<Map<UUID, Movie>>
|
||||
val series: StateFlow<Map<UUID, Series>>
|
||||
val episodes: StateFlow<Map<UUID, Episode>>
|
||||
val genres: StateFlow<Set<Genre>>
|
||||
suspend fun getMovie(id: UUID): Flow<Movie?>
|
||||
suspend fun getSeries(id: UUID): Flow<Series?>
|
||||
suspend fun getEpisode(id: UUID): Flow<Episode?>
|
||||
|
||||
@@ -5,8 +5,13 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.data.MediaCatalogReader
|
||||
import hu.bbara.purefin.data.UserSessionRepository
|
||||
import hu.bbara.purefin.image.ArtworkKind
|
||||
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.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -15,7 +20,7 @@ import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import hu.bbara.purefin.image.ArtworkKind
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -23,8 +28,11 @@ import javax.inject.Inject
|
||||
class SearchViewModel @Inject constructor(
|
||||
private val mediaCatalogReader: MediaCatalogReader,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
) : ViewModel() {
|
||||
|
||||
val genres = mediaCatalogReader.genres
|
||||
|
||||
private val _searchResult = MutableStateFlow<List<SearchResult>>(emptyList())
|
||||
val searchResult = _searchResult.asStateFlow()
|
||||
|
||||
@@ -54,6 +62,34 @@ class SearchViewModel @Inject constructor(
|
||||
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 {
|
||||
return ImageUrlBuilder.toImageUrl(userSessionRepository.serverUrl.first(), id,
|
||||
ArtworkKind.PRIMARY)
|
||||
|
||||
@@ -6,6 +6,7 @@ import hu.bbara.purefin.data.HomeRepository
|
||||
import hu.bbara.purefin.data.NetworkMonitor
|
||||
import hu.bbara.purefin.data.UserSessionRepository
|
||||
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.toMovie
|
||||
import hu.bbara.purefin.data.converter.toSeason
|
||||
@@ -103,6 +104,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
loadContinueWatching()
|
||||
loadNextUp()
|
||||
loadLatestLibraryContent()
|
||||
loadGenres()
|
||||
Log.d(TAG, "Home refresh successful")
|
||||
persistHomeCache()
|
||||
}.onFailure { error ->
|
||||
@@ -199,7 +201,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun loadLibraries() {
|
||||
private suspend fun loadLibraries() {
|
||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||
.getOrElse { error ->
|
||||
Log.w(TAG, "Unable to load libraries", error)
|
||||
@@ -221,7 +223,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
onlineMediaRepository.upsertSeries(series)
|
||||
}
|
||||
|
||||
suspend fun loadLibrary(library: Library): Library {
|
||||
private suspend fun loadLibrary(library: Library): Library {
|
||||
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
|
||||
.getOrElse { 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() }
|
||||
.getOrElse { 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() }
|
||||
.getOrElse { 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() }
|
||||
.getOrElse { 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() }
|
||||
.getOrElse { error ->
|
||||
Log.w(TAG, "Unable to load latest library content", error)
|
||||
@@ -337,6 +339,12 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
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 {
|
||||
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.jellyfin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Genre
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.model.Series
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -48,6 +49,9 @@ class InMemoryMediaRepository @Inject constructor(
|
||||
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
|
||||
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?> {
|
||||
if (!moviesState.value.containsKey(id)) {
|
||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||
@@ -92,6 +96,10 @@ class InMemoryMediaRepository @Inject constructor(
|
||||
return episodesState.map { it[id] }
|
||||
}
|
||||
|
||||
fun upsertGenres(genres: Set<Genre>) {
|
||||
genresState.update { current -> current + genres }
|
||||
}
|
||||
|
||||
fun upsertMovies(movies: List<Movie>) {
|
||||
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.offline.room.offline.OfflineRoomMediaLocalDataSource
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Genre
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.model.Series
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -32,6 +34,8 @@ class OfflineMediaRepository @Inject constructor(
|
||||
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
override var genres: StateFlow<Set<Genre>> = MutableStateFlow(emptySet())
|
||||
|
||||
override suspend fun getMovie(id: UUID): Flow<Movie?> {
|
||||
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.ImageUrlBuilder
|
||||
import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Genre
|
||||
import hu.bbara.purefin.model.Library
|
||||
import hu.bbara.purefin.model.LibraryKind
|
||||
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 {
|
||||
if (date == null) {
|
||||
return fallbackYear?.toString() ?: "—"
|
||||
|
||||
Reference in New Issue
Block a user