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
This commit is contained in:
damontecres 2025-10-27 10:28:27 -04:00 committed by GitHub
parent 1a7684b458
commit 4269d27dad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -2,10 +2,12 @@ package com.github.damontecres.wholphin.ui.main
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -18,11 +20,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards 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.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -57,9 +63,10 @@ class SearchViewModel
val api: ApiClient, val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
val movies = MutableLiveData<List<BaseItem?>>(listOf()) val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val series = MutableLiveData<List<BaseItem?>>(listOf()) val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val episodes = MutableLiveData<List<BaseItem?>>(listOf()) val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery)
private var currentQuery: String? = null private var currentQuery: String? = null
@ -68,53 +75,48 @@ class SearchViewModel
return return
} }
currentQuery = query currentQuery = query
movies.value = listOf()
series.value = listOf()
episodes.value = listOf()
if (query.isNotNullOrBlank()) { if (query.isNotNullOrBlank()) {
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<SearchResult>,
) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
try {
val request = val request =
GetItemsRequest( GetItemsRequest(
searchTerm = query, searchTerm = query,
recursive = true, recursive = true,
includeItemTypes = listOf(BaseItemKind.MOVIE), includeItemTypes = listOf(type),
fields = SlimItemFields, fields = SlimItemFields,
limit = 25, limit = 25,
) )
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) val pager =
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
pager.init() pager.init()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
movies.value = pager target.value = SearchResult.Success(pager)
} }
} } catch (ex: Exception) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { Timber.e(ex, "Exception searching for $type")
val request =
GetItemsRequest(
searchTerm = query,
recursive = true,
includeItemTypes = listOf(BaseItemKind.SERIES),
fields = SlimItemFields,
limit = 25,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
pager.init()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
series.value = pager target.value = SearchResult.Error(ex)
}
}
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
} }
} }
} }
@ -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<BaseItem?>,
) : SearchResult
}
private const val MOVIE_ROW = 0 private const val MOVIE_ROW = 0
private const val SERIES_ROW = 1 private const val COLLECTION_ROW = MOVIE_ROW + 1
private const val EPISODE_ROW = 2 private const val SERIES_ROW = COLLECTION_ROW + 1
private const val EPISODE_ROW = SERIES_ROW + 1
@Composable @Composable
fun SearchPage( fun SearchPage(
@ -136,9 +153,10 @@ fun SearchPage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SearchViewModel = hiltViewModel(), viewModel: SearchViewModel = hiltViewModel(),
) { ) {
val movies by viewModel.movies.observeAsState(listOf()) val movies by viewModel.movies.observeAsState(SearchResult.NoQuery)
val series by viewModel.series.observeAsState(listOf()) val collections by viewModel.collections.observeAsState(SearchResult.NoQuery)
val episodes by viewModel.episodes.observeAsState(listOf()) val series by viewModel.series.observeAsState(SearchResult.NoQuery)
val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery)
var query by rememberSaveable { mutableStateOf("") } var query by rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -179,75 +197,45 @@ fun SearchPage(
) )
} }
} }
if (movies.isNotEmpty()) { searchResultRow(
item {
ItemRow(
title = "Movies", title = "Movies",
items = movies, result = movies,
onClickItem = { rowIndex = MOVIE_ROW,
viewModel.navigationManager.navigateTo(it.destination()) position = position,
}, focusRequester = focusRequester,
onLongClickItem = {}, onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) },
modifier = Modifier, onClickPosition = { position = it },
cardContent = @Composable { index, item, mod, onClick, onLongClick -> modifier = Modifier.fillMaxWidth(),
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 = "Collections",
result = collections,
rowIndex = COLLECTION_ROW,
position = position,
focusRequester = focusRequester,
onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) },
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
) )
} searchResultRow(
}
if (series.isNotEmpty()) {
item {
ItemRow(
title = "Series", title = "Series",
items = series, result = series,
onClickItem = { rowIndex = SERIES_ROW,
viewModel.navigationManager.navigateTo(it.destination()) position = position,
}, focusRequester = focusRequester,
onLongClickItem = {}, onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) },
modifier = Modifier, onClickPosition = { position = it },
cardContent = @Composable { index, item, mod, onClick, onLongClick -> modifier = Modifier.fillMaxWidth(),
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),
),
) )
}, searchResultRow(
)
}
}
if (episodes.isNotEmpty()) {
item {
ItemRow(
title = "Episodes", title = "Episodes",
items = episodes, result = episodes,
onClickItem = { rowIndex = EPISODE_ROW,
viewModel.navigationManager.navigateTo(it.destination()) position = position,
}, focusRequester = focusRequester,
onLongClickItem = {}, onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) },
modifier = Modifier, onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
cardContent = @Composable { index, item, mod, onClick, onLongClick -> cardContent = @Composable { index, item, mod, onClick, onLongClick ->
EpisodeCard( EpisodeCard(
item = item, item = item,
@ -268,6 +256,104 @@ fun SearchPage(
}, },
) )
} }
}
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
}
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,
)
}
}