feat(search): add Jellyfin-backed search

This commit is contained in:
2026-05-02 17:40:56 +02:00
parent 36e89f35d9
commit 6433225014
7 changed files with 208 additions and 96 deletions

View File

@@ -113,6 +113,7 @@ fun SearchFullScreen(
onResultClick = viewModel::onSearchResultSelected,
onGenreSelected = { genre ->
selectedGenreName = genre.name.takeIf { it != selectedGenreName }
viewModel.setSelectedGenre(selectedGenreName)
},
modifier = modifier.then(sharedBoundsModifier)
)
@@ -158,21 +159,44 @@ private fun SearchFullScreenContent(
selectedGenreName = selectedGenreName,
onGenreSelected = onGenreSelected
)
if (selectedGenreName != null) {
Spacer(modifier = Modifier.height(30.dp))
SectionTitle(text = "Search Results")
Spacer(modifier = Modifier.height(16.dp))
SearchResults(
searchResults = searchResults,
onResultClick = onResultClick
)
}
} 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,
SearchResults(
searchResults = searchResults,
onResultClick = onResultClick
)
}
}
}
@Composable
private fun SearchResults(
searchResults: List<SearchResult>,
onResultClick: (SearchResult) -> Unit,
modifier: Modifier = Modifier
) {
if (searchResults.isEmpty()) {
SearchMessage(
title = "No matches",
body = "Try a different title or browse your libraries.",
modifier = modifier
)
} else {
SearchResultsGrid(
results = searchResults,
onResultClick = onResultClick,
modifier = modifier
)
}
}

View File

@@ -1,14 +1,18 @@
package hu.bbara.purefin.ui.screen.home.components.search
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -17,8 +21,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@Composable
@@ -29,43 +33,50 @@ internal fun SearchResultCard(
) {
val scheme = MaterialTheme.colorScheme
Surface(
shape = RoundedCornerShape(22.dp),
color = scheme.surfaceContainer,
Card(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(22.dp))
.clickable(onClick = onClick)
) {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(12.dp)
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 10f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(scheme.surface)
) {
PurefinAsyncImage(
model = item.posterUrl,
contentDescription = item.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(18.dp))
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.height(10.dp))
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = when (item.type) {
MediaKind.MOVIE -> "Movie"
MediaKind.SERIES -> "Series"
else -> "Title"
},
style = MaterialTheme.typography.labelMedium,
color = scheme.onSurfaceVariant
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -0,0 +1,12 @@
package hu.bbara.purefin.data
import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.model.Genre
import kotlinx.coroutines.flow.StateFlow
interface SearchManager {
val searchResult : StateFlow<List<SearchResult>>
val genres: StateFlow<Set<Genre>>
fun setGenres(genres : Set<String>)
fun setSearchTerm(searchTerm: String)
}

View File

@@ -1,65 +1,31 @@
package hu.bbara.purefin.feature.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.data.SearchManager
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
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
@OptIn(FlowPreview::class)
class SearchViewModel @Inject constructor(
private val mediaCatalogReader: LocalMediaRepository,
private val userSessionRepository: UserSessionRepository,
private val searchManager: SearchManager,
private val navigationManager: NavigationManager,
) : ViewModel() {
val genres = mediaCatalogReader.genres
private val _searchResult = MutableStateFlow<List<SearchResult>>(emptyList())
val searchResult = _searchResult.asStateFlow()
private val query = MutableStateFlow("")
init {
combine(
query.debounce(300).distinctUntilChanged(),
mediaCatalogReader.movies,
mediaCatalogReader.series
) { currentQuery, movies, series ->
val filteredMovies = movies.filter {
it.value.title.contains(currentQuery, ignoreCase = true)
}
val filteredSeries = series.filter {
it.value.name.contains(currentQuery, ignoreCase = true)
}
_searchResult.value = filteredMovies.values.map {
SearchResult.create(it, createImageUrl(it.id))
} + filteredSeries.values.map {
SearchResult.create(it, createImageUrl(it.id))
}
}.launchIn(viewModelScope)
}
val genres = searchManager.genres
val searchResult = searchManager.searchResult
fun search(query: String) {
this.query.value = query
searchManager.setSearchTerm(query)
}
fun setSelectedGenre(genreName: String?) {
searchManager.setGenres(genreName?.let { setOf(it) } ?: emptySet())
}
fun onSearchResultSelected(searchResult: SearchResult) {
@@ -90,8 +56,4 @@ class SearchViewModel @Inject constructor(
)
}
private suspend fun createImageUrl(id: UUID) : String {
return ImageUrlBuilder.toImageUrl(userSessionRepository.serverUrl.first(), id,
ArtworkKind.PRIMARY)
}
}

View File

@@ -0,0 +1,96 @@
package hu.bbara.purefin.data.jellyfin
import hu.bbara.purefin.data.SearchManager
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.MediaKind
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SearchManagerImpl @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository
) : SearchManager {
private suspend fun getServerUrl(): String {
return userSessionRepository.serverUrl.first()
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val searchTerm = MutableStateFlow("")
private val selectedGenres = MutableStateFlow<Set<String>>(emptySet())
private val _genres = MutableStateFlow<Set<Genre>>(emptySet())
override val searchResult: StateFlow<List<SearchResult>> =
combine(searchTerm, selectedGenres) { searchTerm, genres ->
search(searchTerm, genres)
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val genres: StateFlow<Set<Genre>> = _genres.asStateFlow()
init {
scope.launch {
_genres.value = jellyfinApiClient.getGenres()
.mapNotNull { it.name }
.map { Genre(name = it) }
.toSet()
}
}
override fun setGenres(genres: Set<String>) {
selectedGenres.value = genres
}
override fun setSearchTerm(searchTerm: String) {
this.searchTerm.value = searchTerm
}
private suspend fun search(searchTerm: String, genres: Set<String>): List<SearchResult> {
if (searchTerm.isNotEmpty()) {
val searchBySearchTerm = jellyfinApiClient.searchBySearchTerm(searchTerm)
return searchBySearchTerm.map { it.toSearchResult() }
}
if (genres.isNotEmpty()) {
val searchByGenre = jellyfinApiClient.searchByGenre(genres)
return searchByGenre.map { it.toSearchResult() }
}
return emptyList()
}
private suspend fun BaseItemDto.toSearchResult(): SearchResult {
return SearchResult(
id = id,
title = name!!,
posterUrl = ImageUrlBuilder.toImageUrl(
url = getServerUrl(),
itemId = id,
artworkKind = ArtworkKind.PRIMARY
),
type = when (type) {
BaseItemKind.MOVIE -> MediaKind.MOVIE
BaseItemKind.SERIES -> MediaKind.SERIES
else -> throw UnsupportedOperationException("Unsupported media type: $type")
}
)
}
}

View File

@@ -116,13 +116,14 @@ class JellyfinApiClient @Inject constructor(
userId = getUserId(),
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
searchTerm = searchTerm,
recursive = true
)
Log.d("searchBySearchTerm", response.content.toString())
response.content.items
}
}
suspend fun searchByGenre(genres: List<String>): List<BaseItemDto> = withContext(Dispatchers.IO) {
suspend fun searchByGenre(genres: Set<String>): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("searchMovie") {
if (!ensureConfigured()) {
return@logApiFailure emptyList()
@@ -130,7 +131,8 @@ class JellyfinApiClient @Inject constructor(
val response = api.itemsApi.getItems(
userId = getUserId(),
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
genres = genres
genres = genres,
recursive = true
)
Log.d("searchByGenre", response.content.toString())
response.content.items

View File

@@ -8,9 +8,11 @@ import hu.bbara.purefin.Offline
import hu.bbara.purefin.Online
import hu.bbara.purefin.data.HomeRepository
import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.data.SearchManager
import hu.bbara.purefin.data.catalog.InMemoryAppContentRepository
import hu.bbara.purefin.data.catalog.InMemoryLocalMediaRepository
import hu.bbara.purefin.data.catalog.OfflineLocalMediaRepository
import hu.bbara.purefin.data.jellyfin.SearchManagerImpl
@Module
@InstallIn(SingletonComponent::class)
@@ -26,4 +28,7 @@ abstract class MediaRepositoryModule {
@Binds
abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository
@Binds
abstract fun bindSearchManager(impl: SearchManagerImpl): SearchManager
}