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.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)
)
}
)
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")
if (query.isBlank()) {
SectionTitle(text = "Browse Genres")
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)
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
)
}
}
}
}
@@ -200,46 +233,45 @@ 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)
},
trailingIcon = {
if (query.isNotBlank()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Clear search",
tint = scheme.onSurfaceVariant
)
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))
}
} else {
Icon(
imageVector = Icons.Outlined.Tune,
contentDescription = "Filter",
@@ -247,6 +279,54 @@ private fun SearchField(
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
FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier.fillMaxWidth()
) {
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)
)
genres.forEach { genre ->
FilterChip(
selected = genre.name == selectedGenreName,
onClick = { onGenreSelected(genre) },
label = {
Text(
text = title,
text = genre.name,
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()
) {
categories.chunked(2).forEach { rowItems ->
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
rowItems.forEach { category ->
CategoryCard(
category = category,
modifier = Modifier.weight(1f)
)
}
if (rowItems.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun CategoryCard(
category: SearchCategory,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(18.dp)
Surface(
shape = shape,
color = scheme.surfaceContainer,
modifier = modifier
.clip(shape)
.aspectRatio(1.45f)
) {
Box(modifier = Modifier.fillMaxSize()) {
PurefinAsyncImage(
model = category.imageUrl,
contentDescription = category.name,
fallbackIcon = Icons.Outlined.Movie,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.18f),
Color.Black.copy(alpha = 0.42f),
Color.Black.copy(alpha = 0.76f)
)
)
)
)
Text(
text = category.name,
style = MaterialTheme.typography.titleLarge,
color = Color.White,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
)
}
}
@@ -389,47 +385,43 @@ private fun CategoryCard(
@Preview(showBackground = true)
@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
)
)

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

View File

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

View File

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

View File

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

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

View File

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

View File

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