feat(search): wire full-screen search with genre browsing

This commit is contained in:
2026-05-02 15:15:20 +02:00
parent c6fe84654f
commit 672d8e1d64
9 changed files with 301 additions and 234 deletions

View File

@@ -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 = { Spacer(modifier = Modifier.height(18.dp))
Icon( GenreChips(
imageVector = Icons.Outlined.LocalFireDepartment, genres = genres,
contentDescription = null, selectedGenreName = selectedGenreName,
tint = scheme.primary, onGenreSelected = onGenreSelected
modifier = Modifier.size(26.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
) )
} }
)
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 @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 Icon(
.height(34.dp) imageVector = Icons.Outlined.Tune,
.width(1.dp) contentDescription = "Filter",
.background(scheme.outlineVariant.copy(alpha = 0.18f)) tint = scheme.primary,
) modifier = Modifier.size(30.dp)
Spacer(modifier = Modifier.width(14.dp)) )
Icon( }
imageVector = Icons.Outlined.Tune, },
contentDescription = "Filter", shape = RoundedCornerShape(24.dp),
tint = scheme.primary, colors = TextFieldDefaults.colors(
modifier = Modifier.size(30.dp) 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),
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),
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
categories.chunked(2).forEach { rowItems -> genres.forEach { genre ->
Row( FilterChip(
horizontalArrangement = Arrangement.spacedBy(16.dp), selected = genre.name == selectedGenreName,
modifier = Modifier.fillMaxWidth() onClick = { onGenreSelected(genre) },
) { label = {
rowItems.forEach { category -> Text(
CategoryCard( text = genre.name,
category = category, style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f) 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) @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"
) )
) )

View File

@@ -0,0 +1,5 @@
package hu.bbara.purefin.model
data class Genre(
val name: String
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() ?: ""