From 4269d27dad1dee5a16a78a837fe1684fd6fbd8e7 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Mon, 27 Oct 2025 10:28:27 -0400 Subject: [PATCH] Search page improvements (#76) Update the search page results to indicate whether 1) the search is in progress, 2) there are no results, or 3) the actual results. This makes it clear when there's actually no results versus the search is just running slowly. Also search for collections as well --- .../wholphin/ui/main/SearchPage.kt | 340 +++++++++++------- 1 file changed, 213 insertions(+), 127 deletions(-) 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, + ) + } +}