mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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:
parent
1a7684b458
commit
4269d27dad
1 changed files with 213 additions and 127 deletions
|
|
@ -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,
|
||||
|
|
@ -269,5 +257,103 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue