diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomePage.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomePage.kt index 95a9ab1..0ff8863 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomePage.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/home/TvHomePage.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect -import hu.bbara.purefin.feature.shared.home.HomePageViewModel +import hu.bbara.purefin.feature.shared.home.AppViewModel import hu.bbara.purefin.tv.home.ui.TvHomeContent import hu.bbara.purefin.tv.home.ui.TvHomeDrawerContent import hu.bbara.purefin.tv.home.ui.TvHomeMockData @@ -31,7 +31,7 @@ import org.jellyfin.sdk.model.api.CollectionType @Composable fun TvHomePage( - viewModel: HomePageViewModel = hiltViewModel(), + viewModel: AppViewModel = hiltViewModel(), modifier: Modifier = Modifier ) { val drawerState = rememberDrawerState(DrawerValue.Closed) diff --git a/app/src/main/java/hu/bbara/purefin/app/home/AppScreen.kt b/app/src/main/java/hu/bbara/purefin/app/home/AppScreen.kt index 43cfecf..0724148 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/AppScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/AppScreen.kt @@ -11,11 +11,11 @@ import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect import hu.bbara.purefin.app.home.ui.HomeNavItem -import hu.bbara.purefin.feature.shared.home.HomePageViewModel +import hu.bbara.purefin.feature.shared.home.AppViewModel @Composable fun AppScreen( - viewModel: HomePageViewModel = hiltViewModel(), + viewModel: AppViewModel = hiltViewModel(), modifier: Modifier = Modifier ) { var selectedTab by remember { mutableIntStateOf(0) } diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt index 61702f3..023068a 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt @@ -7,23 +7,24 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Cloud -import androidx.compose.material.icons.outlined.CloudOff import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import hu.bbara.purefin.common.ui.components.PurefinIconButton -import hu.bbara.purefin.common.ui.components.SearchField +import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.common.ui.components.PurefinSearchBar +import hu.bbara.purefin.feature.shared.search.SearchViewModel @Composable fun HomeTopBar( modifier: Modifier = Modifier, + searchViewModel: SearchViewModel = hiltViewModel() ) { val scheme = MaterialTheme.colorScheme + val searchResult = searchViewModel.searchResult.collectAsState() Box( modifier = modifier @@ -37,15 +38,16 @@ fun HomeTopBar( .padding(horizontal = 16.dp, vertical = 16.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.Start), ) { - SearchField( - value = "", - onValueChange = {}, - placeholder = "Search", - backgroundColor = scheme.secondaryContainer, - textColor = scheme.onSecondaryContainer, - cursorColor = scheme.onSecondaryContainer, + PurefinSearchBar( + onQueryChange = { + searchViewModel.search(it) + }, + onSearch = { + searchViewModel.search(it) + }, + searchResults = searchResult.value, modifier = Modifier.weight(1.0f, true), ) } diff --git a/app/src/main/java/hu/bbara/purefin/common/ui/components/PurefinSeachBar.kt b/app/src/main/java/hu/bbara/purefin/common/ui/components/PurefinSeachBar.kt new file mode 100644 index 0000000..47cfeaf --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/common/ui/components/PurefinSeachBar.kt @@ -0,0 +1,106 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.layout.ContentScale +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.unit.dp +import hu.bbara.purefin.core.model.SearchResult + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PurefinSearchBar( + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + searchResults: List, + modifier: Modifier = Modifier +) { + var query by remember { mutableStateOf("") } + var expanded by rememberSaveable { mutableStateOf(false) } + + Box( + modifier + .fillMaxWidth() + .semantics { isTraversalGroup = true } + ) { + SearchBar( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .semantics { traversalIndex = 0f }, + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = { + query = it + onQueryChange(it) + }, + onSearch = { + onSearch(query) + expanded = false + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + placeholder = { Text("Search") } + ) + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 120.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier.background(MaterialTheme.colorScheme.background) + ) { + items(searchResults) { item -> + SearchResultCard(item) + } + } + } + } +} + +@Composable +private fun SearchResultCard( + item: SearchResult, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + PurefinAsyncImage( + model = item.posterUrl, + contentDescription = item.title, + modifier = Modifier.height(150.dp), + contentScale = ContentScale.Fit + ) + Text( + text = item.title, + modifier = Modifier.padding(horizontal = 8.dp) + ) + } +} diff --git a/core/model/src/main/java/hu/bbara/purefin/core/model/SearchResult.kt b/core/model/src/main/java/hu/bbara/purefin/core/model/SearchResult.kt new file mode 100644 index 0000000..e4209bb --- /dev/null +++ b/core/model/src/main/java/hu/bbara/purefin/core/model/SearchResult.kt @@ -0,0 +1,31 @@ +package hu.bbara.purefin.core.model + +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind + +data class SearchResult( + val id: UUID, + val title: String, + val posterUrl: String, + val type: BaseItemKind, +) { + companion object { + fun create(movie: Movie, imageUrl: String) : SearchResult { + return SearchResult( + id = movie.id, + title = movie.title, + posterUrl = imageUrl, + type = BaseItemKind.MOVIE + ) + } + + fun create(series: Series, imageUrl: String) : SearchResult { + return SearchResult( + id = series.id, + title = series.name, + posterUrl = imageUrl, + type = BaseItemKind.MOVIE + ) + } + } +} diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/AppViewModel.kt similarity index 99% rename from feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt rename to feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/AppViewModel.kt index 5562a33..c3bd28d 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/HomePageViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/AppViewModel.kt @@ -12,8 +12,8 @@ import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.data.session.UserSessionRepository -import hu.bbara.purefin.feature.download.MediaDownloadManager import hu.bbara.purefin.core.model.Media +import hu.bbara.purefin.feature.download.MediaDownloadManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -28,7 +28,7 @@ import org.jellyfin.sdk.model.api.CollectionType import javax.inject.Inject @HiltViewModel -class HomePageViewModel @Inject constructor( +class AppViewModel @Inject constructor( private val appContentRepository: AppContentRepository, private val userSessionRepository: UserSessionRepository, private val navigationManager: NavigationManager, diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/search/SearchViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/search/SearchViewModel.kt new file mode 100644 index 0000000..d2f3ed8 --- /dev/null +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/search/SearchViewModel.kt @@ -0,0 +1,62 @@ +package hu.bbara.purefin.feature.shared.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import hu.bbara.purefin.core.data.MediaRepository +import hu.bbara.purefin.core.data.image.JellyfinImageHelper +import hu.bbara.purefin.core.data.session.UserSessionRepository +import hu.bbara.purefin.core.model.SearchResult +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 org.jellyfin.sdk.model.api.ImageType +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +@OptIn(FlowPreview::class) +class SearchViewModel @Inject constructor( + private val mediaRepository: MediaRepository, + private val userSessionRepository: UserSessionRepository +) : ViewModel() { + + private val _searchResult = MutableStateFlow>(emptyList()) + val searchResult = _searchResult.asStateFlow() + + private val query = MutableStateFlow("") + + init { + combine( + query.debounce(300).distinctUntilChanged(), + mediaRepository.movies, + mediaRepository.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) + } + + fun search(query: String) { + this.query.value = query + } + + private suspend fun createImageUrl(id: UUID) : String { + return JellyfinImageHelper.toImageUrl(userSessionRepository.serverUrl.first(), id, + ImageType.PRIMARY) + } +} \ No newline at end of file