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.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<List<BaseItem?>>(listOf())
val series = MutableLiveData<List<BaseItem?>>(listOf())
val episodes = MutableLiveData<List<BaseItem?>>(listOf())
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val collections = MutableLiveData<SearchResult>(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()) {
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) {
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<BaseItem?>,
) : 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,75 +197,45 @@ fun SearchPage(
)
}
}
if (movies.isNotEmpty()) {
item {
ItemRow(
searchResultRow(
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),
),
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(),
)
}
}
if (series.isNotEmpty()) {
item {
ItemRow(
searchResultRow(
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),
),
result = series,
rowIndex = SERIES_ROW,
position = position,
focusRequester = focusRequester,
onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) },
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
)
},
)
}
}
if (episodes.isNotEmpty()) {
item {
ItemRow(
searchResultRow(
title = "Episodes",
items = episodes,
onClickItem = {
viewModel.navigationManager.navigateTo(it.destination())
},
onLongClickItem = {},
modifier = Modifier,
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,
@ -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,
)
}
}