diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index 27382274..341f8c86 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -2,10 +2,12 @@ package com.github.damontecres.wholphin.ui.main 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.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -18,11 +20,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.Cards @@ -48,6 +53,7 @@ import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -57,9 +63,10 @@ class SearchViewModel val api: ApiClient, val navigationManager: NavigationManager, ) : ViewModel() { - val movies = MutableLiveData>(listOf()) - val series = MutableLiveData>(listOf()) - val episodes = MutableLiveData>(listOf()) + val movies = MutableLiveData(SearchResult.NoQuery) + val series = MutableLiveData(SearchResult.NoQuery) + val episodes = MutableLiveData(SearchResult.NoQuery) + val collections = MutableLiveData(SearchResult.NoQuery) private var currentQuery: String? = null @@ -68,53 +75,48 @@ class SearchViewModel return } currentQuery = query - movies.value = listOf() - series.value = listOf() - episodes.value = listOf() if (query.isNotNullOrBlank()) { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + movies.value = SearchResult.Searching + series.value = SearchResult.Searching + episodes.value = SearchResult.Searching + collections.value = SearchResult.Searching + searchInternal(query, BaseItemKind.MOVIE, movies) + searchInternal(query, BaseItemKind.SERIES, series) + searchInternal(query, BaseItemKind.EPISODE, episodes) + searchInternal(query, BaseItemKind.BOX_SET, collections) + } else { + movies.value = SearchResult.NoQuery + series.value = SearchResult.NoQuery + episodes.value = SearchResult.NoQuery + collections.value = SearchResult.NoQuery + } + } + + private fun searchInternal( + query: String, + type: BaseItemKind, + target: MutableLiveData, + ) { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + try { val request = GetItemsRequest( searchTerm = query, recursive = true, - includeItemTypes = listOf(BaseItemKind.MOVIE), + includeItemTypes = listOf(type), fields = SlimItemFields, limit = 25, ) - val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) pager.init() withContext(Dispatchers.Main) { - movies.value = pager + target.value = SearchResult.Success(pager) } - } - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - val request = - GetItemsRequest( - searchTerm = query, - recursive = true, - includeItemTypes = listOf(BaseItemKind.SERIES), - fields = SlimItemFields, - limit = 25, - ) - val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - pager.init() + } catch (ex: Exception) { + Timber.e(ex, "Exception searching for $type") withContext(Dispatchers.Main) { - series.value = pager - } - } - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - val request = - GetItemsRequest( - searchTerm = query, - recursive = true, - includeItemTypes = listOf(BaseItemKind.EPISODE), - fields = SlimItemFields, - limit = 25, - ) - val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - pager.init() - withContext(Dispatchers.Main) { - episodes.value = pager + target.value = SearchResult.Error(ex) } } } @@ -126,9 +128,24 @@ class SearchViewModel } } +sealed interface SearchResult { + data object NoQuery : SearchResult + + data object Searching : SearchResult + + data class Error( + val ex: Exception, + ) : SearchResult + + data class Success( + val items: List, + ) : SearchResult +} + private const val MOVIE_ROW = 0 -private const val SERIES_ROW = 1 -private const val EPISODE_ROW = 2 +private const val COLLECTION_ROW = MOVIE_ROW + 1 +private const val SERIES_ROW = COLLECTION_ROW + 1 +private const val EPISODE_ROW = SERIES_ROW + 1 @Composable fun SearchPage( @@ -136,9 +153,10 @@ fun SearchPage( modifier: Modifier = Modifier, viewModel: SearchViewModel = hiltViewModel(), ) { - val movies by viewModel.movies.observeAsState(listOf()) - val series by viewModel.series.observeAsState(listOf()) - val episodes by viewModel.episodes.observeAsState(listOf()) + val movies by viewModel.movies.observeAsState(SearchResult.NoQuery) + val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) + val series by viewModel.series.observeAsState(SearchResult.NoQuery) + val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) var query by rememberSaveable { mutableStateOf("") } val focusRequester = remember { FocusRequester() } @@ -179,95 +197,163 @@ fun SearchPage( ) } } - if (movies.isNotEmpty()) { - item { - ItemRow( - title = "Movies", - items = movies, - onClickItem = { - viewModel.navigationManager.navigateTo(it.destination()) - }, - onLongClickItem = {}, - modifier = Modifier, - cardContent = @Composable { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - position = RowColumn(MOVIE_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.height2x3, - modifier = - mod - .ifElse( - position.row == MOVIE_ROW && position.column == index, - Modifier.focusRequester(focusRequester), - ), - ) + searchResultRow( + title = "Movies", + result = movies, + rowIndex = MOVIE_ROW, + position = position, + focusRequester = focusRequester, + onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) + searchResultRow( + title = "Collections", + result = collections, + rowIndex = COLLECTION_ROW, + position = position, + focusRequester = focusRequester, + onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) + searchResultRow( + title = "Series", + result = series, + rowIndex = SERIES_ROW, + position = position, + focusRequester = focusRequester, + onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) + searchResultRow( + title = "Episodes", + result = episodes, + rowIndex = EPISODE_ROW, + position = position, + focusRequester = focusRequester, + onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + cardContent = @Composable { index, item, mod, onClick, onLongClick -> + EpisodeCard( + item = item, + onClick = { + position = RowColumn(EPISODE_ROW, index) + onClick.invoke() }, + onLongClick = onLongClick, + imageHeight = 140.dp, + modifier = + mod + .padding(horizontal = 8.dp) + .ifElse( + position.row == EPISODE_ROW && position.column == index, + Modifier.focusRequester(focusRequester), + ), ) + }, + ) + } +} + +fun LazyListScope.searchResultRow( + title: String, + result: SearchResult, + rowIndex: Int, + position: RowColumn, + focusRequester: FocusRequester, + onClickItem: (BaseItem) -> Unit, + onClickPosition: (RowColumn) -> Unit, + modifier: Modifier = Modifier, + cardContent: @Composable ( + index: Int, + item: BaseItem?, + modifier: Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit, + ) -> Unit = @Composable { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + onClickPosition.invoke(RowColumn(rowIndex, index)) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.height2x3, + modifier = + mod + .ifElse( + position.row == rowIndex && position.column == index, + Modifier.focusRequester(focusRequester), + ), + ) + }, +) { + item { + when (val r = result) { + is SearchResult.Error -> + SearchResultPlaceholder( + title = title, + message = r.ex.localizedMessage ?: "Error occurred during search", + messageColor = MaterialTheme.colorScheme.error, + modifier = Modifier, + ) + + SearchResult.NoQuery -> { + // no-op } - } - if (series.isNotEmpty()) { - item { - ItemRow( - title = "Series", - items = series, - onClickItem = { - viewModel.navigationManager.navigateTo(it.destination()) - }, - onLongClickItem = {}, - modifier = Modifier, - cardContent = @Composable { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - position = RowColumn(SERIES_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.height2x3, - modifier = - mod.ifElse( - position.row == SERIES_ROW && position.column == index, - Modifier.focusRequester(focusRequester), - ), - ) - }, - ) - } - } - if (episodes.isNotEmpty()) { - item { - ItemRow( - title = "Episodes", - items = episodes, - onClickItem = { - viewModel.navigationManager.navigateTo(it.destination()) - }, - onLongClickItem = {}, - modifier = Modifier, - cardContent = @Composable { index, item, mod, onClick, onLongClick -> - EpisodeCard( - item = item, - onClick = { - position = RowColumn(EPISODE_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = 140.dp, - modifier = - mod - .padding(horizontal = 8.dp) - .ifElse( - position.row == EPISODE_ROW && position.column == index, - Modifier.focusRequester(focusRequester), - ), - ) - }, + + SearchResult.Searching -> + SearchResultPlaceholder( + title = title, + message = "Searching...", + modifier = modifier, ) + + is SearchResult.Success -> { + if (r.items.isEmpty()) { + SearchResultPlaceholder( + title = title, + message = "No results", + modifier = modifier, + ) + } else { + ItemRow( + title = title, + items = r.items, + onClickItem = onClickItem, + onLongClickItem = {}, + modifier = modifier, + cardContent = cardContent, + ) + } } } } } + +@Composable +fun SearchResultPlaceholder( + title: String, + message: String, + modifier: Modifier = Modifier, + messageColor: Color = MaterialTheme.colorScheme.onBackground, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.padding(bottom = 32.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = message, + style = MaterialTheme.typography.titleMedium, + color = messageColor, + ) + } +}