From 672d8e1d6437a0c579fcbed84605abf7986cfd30 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sat, 2 May 2026 15:15:20 +0200 Subject: [PATCH] feat(search): wire full-screen search with genre browsing --- .../components/search/SearchFullScreen.kt | 436 +++++++++--------- .../main/java/hu/bbara/purefin/model/Genre.kt | 5 + .../purefin/data/CompositeMediaRepository.kt | 13 +- .../bbara/purefin/data/MediaCatalogReader.kt | 2 + .../purefin/feature/search/SearchViewModel.kt | 40 +- .../catalog/InMemoryAppContentRepository.kt | 20 +- .../data/catalog/InMemoryMediaRepository.kt | 8 + .../data/catalog/OfflineMediaRepository.kt | 4 + .../data/converter/BaseItemDtoConverter.kt | 7 + 9 files changed, 301 insertions(+), 234 deletions(-) create mode 100644 core-model/src/main/java/hu/bbara/purefin/model/Genre.kt diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/search/SearchFullScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/search/SearchFullScreen.kt index dfe2bdf2..17c2a98c 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/search/SearchFullScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/search/SearchFullScreen.kt @@ -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(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, + genres: List, + 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, + 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, + 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, - 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 ) ) diff --git a/core-model/src/main/java/hu/bbara/purefin/model/Genre.kt b/core-model/src/main/java/hu/bbara/purefin/model/Genre.kt new file mode 100644 index 00000000..c138d597 --- /dev/null +++ b/core-model/src/main/java/hu/bbara/purefin/model/Genre.kt @@ -0,0 +1,5 @@ +package hu.bbara.purefin.model + +data class Genre( + val name: String +) diff --git a/core/src/main/java/hu/bbara/purefin/data/CompositeMediaRepository.kt b/core/src/main/java/hu/bbara/purefin/data/CompositeMediaRepository.kt index 5af954c5..69ea5216 100644 --- a/core/src/main/java/hu/bbara/purefin/data/CompositeMediaRepository.kt +++ b/core/src/main/java/hu/bbara/purefin/data/CompositeMediaRepository.kt @@ -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> = activeRepository .flatMapLatest { it.movies } - .stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap()) + .stateIn(scope, Eagerly, emptyMap()) override val series: StateFlow> = activeRepository .flatMapLatest { it.series } - .stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap()) + .stateIn(scope, Eagerly, emptyMap()) override val episodes: StateFlow> = activeRepository .flatMapLatest { it.episodes } - .stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap()) + .stateIn(scope, Eagerly, emptyMap()) + + override val genres: StateFlow> = activeRepository + .flatMapLatest { it.genres } + .stateIn(scope, Eagerly, emptySet()) override suspend fun getMovie(id: UUID): Flow { return activeRepository diff --git a/core/src/main/java/hu/bbara/purefin/data/MediaCatalogReader.kt b/core/src/main/java/hu/bbara/purefin/data/MediaCatalogReader.kt index d5c27c68..f8cd13ff 100644 --- a/core/src/main/java/hu/bbara/purefin/data/MediaCatalogReader.kt +++ b/core/src/main/java/hu/bbara/purefin/data/MediaCatalogReader.kt @@ -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> val series: StateFlow> val episodes: StateFlow> + val genres: StateFlow> suspend fun getMovie(id: UUID): Flow suspend fun getSeries(id: UUID): Flow suspend fun getEpisode(id: UUID): Flow diff --git a/core/src/main/java/hu/bbara/purefin/feature/search/SearchViewModel.kt b/core/src/main/java/hu/bbara/purefin/feature/search/SearchViewModel.kt index 5fc1fea7..3452874b 100644 --- a/core/src/main/java/hu/bbara/purefin/feature/search/SearchViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/feature/search/SearchViewModel.kt @@ -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>(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) diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt index 039370d9..f60e2b08 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt @@ -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() } diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryMediaRepository.kt index a7a4af15..bb363575 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryMediaRepository.kt @@ -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>(emptyMap()) override val episodes: StateFlow> = episodesState.asStateFlow() + private val genresState = MutableStateFlow>(emptySet()) + override var genres: StateFlow> = genresState.asStateFlow() + override suspend fun getMovie(id: UUID): Flow { 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) { + genresState.update { current -> current + genres } + } + fun upsertMovies(movies: List) { moviesState.update { current -> current + movies.associateBy { it.id } } } diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineMediaRepository.kt index 71d9ddb7..4c30beca 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/OfflineMediaRepository.kt @@ -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> = localDataSource.episodesFlow .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + override var genres: StateFlow> = MutableStateFlow(emptySet()) + override suspend fun getMovie(id: UUID): Flow { return movies.map { it[id] } } diff --git a/data/src/main/java/hu/bbara/purefin/data/converter/BaseItemDtoConverter.kt b/data/src/main/java/hu/bbara/purefin/data/converter/BaseItemDtoConverter.kt index bcfd7456..b4cde4d3 100644 --- a/data/src/main/java/hu/bbara/purefin/data/converter/BaseItemDtoConverter.kt +++ b/data/src/main/java/hu/bbara/purefin/data/converter/BaseItemDtoConverter.kt @@ -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() ?: "—"