Search page improvements (#709)

## Description

Improves how search results are displayed:

- Reorders search categories to Movies → TV Shows → Episodes →
Collections, surfacing the most commonly searched content types at the
top.
- Adds a new "Combined search results" preference in Advanced settings →
Search. When enabled, shows all results in a single list instead of
separate categories.
- When Seerr is active, adds Library/Discover tabs to switch between
local library results and Seerr discovery results.
- Hides empty result categories instead of showing "No results"
placeholders.

### Screenshots

<img width="1935" height="1100" alt="Screenshot_20260116_113656"
src="https://github.com/user-attachments/assets/1e29aa3a-3ba2-4f86-ab61-fed683490474"
/>

<img width="1934" height="1095" alt="image"
src="https://github.com/user-attachments/assets/8b93fa38-d4c9-4e72-a997-caa82b4949f0"
/>

<img width="1921" height="1094" alt="image"
src="https://github.com/user-attachments/assets/82aed1a5-810c-413f-a266-6d78490a1534"
/>

### AI/LLM usage
Code auditing and PR description drafting with Claude Code. Also used
Claude Code for the regex.
This commit is contained in:
Justin Caveda 2026-05-13 09:02:33 -05:00 committed by GitHub
parent f364ef9708
commit 98ffaf51ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 798 additions and 274 deletions

View file

@ -66,7 +66,8 @@ class AppPreferencesSerializer
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
mediaExtensionsEnabled =
AppPreference.FfmpegPreference.defaultValue
assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
assPlaybackMode =
AppPreference.AssSubtitleMode.defaultValue
}.build()
mpvOptions =
@ -99,6 +100,13 @@ class AppPreferencesSerializer
backdropStyle = AppPreference.BackdropStylePref.defaultValue
showLogos = AppPreference.ShowLogos.defaultValue
searchPreferences =
SearchPreferences
.newBuilder()
.apply {
combinedSearchResults = false
}.build()
subtitlesPreferences =
SubtitlePreferences
.newBuilder()
@ -207,6 +215,11 @@ inline fun AppPreferences.updateInterfacePreferences(block: InterfacePreferences
interfacePreferences = interfacePreferences.toBuilder().apply(block).build()
}
inline fun AppPreferences.updateSearchPreferences(block: SearchPreferences.Builder.() -> Unit): AppPreferences =
updateInterfacePreferences {
searchPreferences = searchPreferences.toBuilder().apply(block).build()
}
inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.Builder.() -> Unit): AppPreferences =
updateInterfacePreferences {
subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build()

View file

@ -338,7 +338,7 @@ fun <T : CardGridItem> CardGrid(
val cardWidth =
ceil((width - (spacingPx * (columns - 1))) / columns)
cardWidthPx = cardWidth.toInt()
Timber.v("cardWidthPx=%s", cardWidthPx)
// Timber.v("cardWidthPx=%s", cardWidthPx)
},
) {
items(pager.size) { index ->

View file

@ -1,21 +1,32 @@
package com.github.damontecres.wholphin.ui.main
import android.view.Gravity
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@ -35,52 +46,73 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.datastore.core.DataStore
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateSearchPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.EpisodeCard
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.ItemRowTitle
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.VoiceInputManager
import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain
import com.github.damontecres.wholphin.ui.preferences.SwitchColors
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.SearchRelevance
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
@ -92,12 +124,19 @@ class SearchViewModel
constructor(
val api: ApiClient,
val navigationManager: NavigationManager,
private val appPreferences: DataStore<AppPreferences>,
private val seerrService: SeerrService,
val voiceInputManager: VoiceInputManager,
) : ViewModel() {
val voiceState = voiceInputManager.state
val soundLevel = voiceInputManager.soundLevel
val partialResult = voiceInputManager.partialResult
val seerrActive = seerrService.active
val combinedModeFlow: StateFlow<Boolean> =
appPreferences.data
.map { it.interfacePreferences.searchPreferences.combinedSearchResults }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
@ -107,17 +146,25 @@ class SearchViewModel
val artists = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val songs = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val combinedResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
private var currentQuery: String? = null
private var combinedMode = false
private val semaphore = Semaphore(4)
fun search(query: String?) {
if (currentQuery == query) {
fun search(
query: String?,
combined: Boolean = false,
) {
if (currentQuery == query && combinedMode == combined) {
return
}
currentQuery = query
combinedMode = combined
if (query.isNotNullOrBlank()) {
if (combined) {
combinedResults.value = SearchResult.Searching
searchCombined(query)
} else {
movies.value = SearchResult.Searching
series.value = SearchResult.Searching
episodes.value = SearchResult.Searching
@ -125,10 +172,11 @@ class SearchViewModel
searchInternal(query, BaseItemKind.MOVIE, movies)
searchInternal(query, BaseItemKind.SERIES, series)
searchInternal(query, BaseItemKind.EPISODE, episodes)
searchInternal(query, BaseItemKind.BOX_SET, collections)
searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums)
searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists)
searchInternal(query, BaseItemKind.AUDIO, songs)
searchInternal(query, BaseItemKind.BOX_SET, collections)
}
searchSeerr(query)
} else {
movies.value = SearchResult.NoQuery
@ -136,6 +184,7 @@ class SearchViewModel
episodes.value = SearchResult.NoQuery
collections.value = SearchResult.NoQuery
seerrResults.value = SearchResult.NoQuery
combinedResults.value = SearchResult.NoQuery
}
}
@ -146,21 +195,26 @@ class SearchViewModel
) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
try {
semaphore.withPermit {
val request =
GetItemsRequest(
searchTerm = query,
recursive = true,
includeItemTypes = listOf(type),
fields = SlimItemFields,
limit = 25,
limit = 50,
)
val pager =
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
pager.init()
withContext(Dispatchers.Main) {
target.value = SearchResult.Success(pager)
val result = api.itemsApi.getItems(request).content
val items =
(result.items ?: emptyList()).map {
BaseItem.from(it, api, false)
}
val sorted =
items.sortedWith(
compareBy<BaseItem> { SearchRelevance.score(it, query) }
.thenBy { it.name ?: "" },
)
withContext(Dispatchers.Main) {
target.value = SearchResult.Success(sorted)
}
} catch (ex: Exception) {
Timber.e(ex, "Exception searching for $type")
@ -171,6 +225,56 @@ class SearchViewModel
}
}
private fun searchCombined(query: String) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
try {
val request =
GetItemsRequest(
searchTerm = query,
recursive = true,
includeItemTypes =
listOf(
BaseItemKind.MOVIE,
BaseItemKind.SERIES,
BaseItemKind.BOX_SET,
),
fields = SlimItemFields,
limit = 50,
)
val result = api.itemsApi.getItems(request).content
val items =
(result.items ?: emptyList()).map {
BaseItem.from(it, api, false)
}
val sorted =
items.sortedWith(
compareBy<BaseItem> { SearchRelevance.score(it, query) }
.thenBy { it.name ?: "" },
)
withContext(Dispatchers.Main) {
combinedResults.value = SearchResult.Success(sorted)
}
} catch (ex: Exception) {
Timber.e(ex, "Exception in combined search")
withContext(Dispatchers.Main) {
combinedResults.value = SearchResult.Error(ex)
}
}
}
}
fun setCombinedResults(enabled: Boolean) {
viewModelScope.launchIO {
appPreferences.updateData {
it.updateSearchPreferences {
combinedSearchResults = enabled
}
}
}
}
private fun searchSeerr(query: String) {
viewModelScope.launchIO {
if (seerrService.active.first()) {
@ -214,14 +318,17 @@ sealed interface SearchResult {
}
private const val SEARCH_ROW = 0
private const val MOVIE_ROW = SEARCH_ROW + 1
private const val TAB_ROW = SEARCH_ROW + 1
private const val MOVIE_ROW = TAB_ROW + 1
private const val SERIES_ROW = MOVIE_ROW + 1
private const val EPISODE_ROW = SERIES_ROW + 1
private const val ALBUM_ROW = EPISODE_ROW + 1
private const val COLLECTION_ROW = EPISODE_ROW + 1
private const val ALBUM_ROW = COLLECTION_ROW + 1
private const val ARTIST_ROW = ALBUM_ROW + 1
private const val SONG_ROW = ARTIST_ROW + 1
private const val COLLECTION_ROW = SONG_ROW + 1
private const val SEERR_ROW = COLLECTION_ROW + 1
private const val SEERR_ROW = SONG_ROW + 1
private const val COMBINED_ROW = TAB_ROW + 1
/** Delay for focus to settle after voice search dialog dismisses. */
private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L
@ -242,10 +349,17 @@ fun SearchPage(
val artists by viewModel.artists.observeAsState(SearchResult.NoQuery)
val songs by viewModel.songs.observeAsState(SearchResult.NoQuery)
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
val combinedResults by viewModel.combinedResults.observeAsState(SearchResult.NoQuery)
val combinedMode by viewModel.combinedModeFlow.collectAsState()
// val query = rememberTextFieldState()
var query by rememberSaveable { mutableStateOf("") }
val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } }
val tabFocusRequesters = remember { List(2) { FocusRequester() } }
val seerrActive by viewModel.seerrActive.collectAsState(initial = false)
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
var showViewOptions by rememberSaveable { mutableStateOf(false) }
var position by rememberPosition(0, 0)
var searchClicked by rememberSaveable { mutableStateOf(false) }
@ -260,10 +374,10 @@ fun SearchPage(
fun triggerImmediateSearch(searchQuery: String) {
immediateSearchQuery = searchQuery
searchClicked = true
viewModel.search(searchQuery)
viewModel.search(searchQuery, combinedMode)
}
LaunchedEffect(query) {
LaunchedEffect(query, combinedMode) {
when {
immediateSearchQuery == query -> {
immediateSearchQuery = null
@ -271,7 +385,7 @@ fun SearchPage(
else -> {
delay(750L)
viewModel.search(query)
viewModel.search(query, combinedMode)
}
}
}
@ -281,33 +395,88 @@ fun SearchPage(
val onClickItem = { index: Int, item: BaseItem ->
viewModel.navigationManager.navigateTo(item.destination())
}
val onPlayItem = { _: Int, item: BaseItem ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
}
LaunchedEffect(searchClicked, movies, collections, series, episodes, seerrResults) {
val onClickDiscover = { _: Int, item: DiscoverItem ->
val dest =
if (item.jellyfinItemId != null && item.type.baseItemKind != null) {
Destination.MediaItem(
itemId = item.jellyfinItemId,
type = item.type.baseItemKind,
)
} else {
Destination.DiscoveredItem(item)
}
viewModel.navigationManager.navigateTo(dest)
}
var showHeader by remember { mutableStateOf(true) }
val positionCallback = { columns: Int, index: Int ->
showHeader = index < columns
}
val showTabs = seerrActive && query.isNotBlank() && showHeader
val isLibraryTab = selectedTab == 0
LaunchedEffect(seerrActive, query) {
if (!seerrActive || query.isBlank()) {
selectedTab = 0
}
}
LaunchedEffect(
searchClicked,
movies,
collections,
series,
episodes,
seerrResults,
combinedResults,
combinedMode,
selectedTab,
seerrActive,
) {
if (!searchClicked) return@LaunchedEffect
withContext(Dispatchers.IO) {
// Want to focus on the first successful row after all of the ones before it are finished searching
val results = listOf(movies, collections, series, episodes, seerrResults)
val results =
if (isLibraryTab) {
if (combinedMode) {
listOf(combinedResults)
} else {
listOf(movies, series, episodes, collections)
}
} else {
listOf(seerrResults)
}
val firstSuccess =
results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr }
if (firstSuccess >= 0) {
val anyBeforeSearching =
results.subList(0, firstSuccess).any { it is SearchResult.Searching }
if (!anyBeforeSearching) {
// 0-th row is the search bar
position = RowColumn(firstSuccess + 1, 0)
onMain { focusRequesters[firstSuccess + 1].tryRequestFocus() }
val targetRow =
if (isLibraryTab) {
if (combinedMode) {
COMBINED_ROW
} else {
MOVIE_ROW + firstSuccess
}
} else {
SEERR_ROW
}
position = RowColumn(targetRow, 0)
onMain { focusRequesters[targetRow].tryRequestFocus() }
}
}
}
}
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.focusGroup(),
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth(),
@ -334,6 +503,7 @@ fun SearchPage(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.focusGroup()
.focusRestorer(textFieldFocusRequester)
.focusRequester(focusRequesters[SEARCH_ROW]),
@ -372,9 +542,87 @@ fun SearchPage(
}
},
)
ExpandableFaButton(
title = R.string.view_options,
iconStringRes = R.string.fa_sliders,
onClick = { showViewOptions = true },
)
}
}
AnimatedVisibility(
visible = showTabs,
enter = expandVertically(),
exit = shrinkVertically(),
) {
TabRow(
selectedTabIndex = selectedTab,
tabs =
listOf(
stringResource(R.string.library),
stringResource(R.string.discover),
),
focusRequesters = tabFocusRequesters,
onClick = {
selectedTab = it
val row =
when (selectedTab) {
0 -> COMBINED_ROW
else -> SEERR_ROW
}
position = RowColumn(row, 0)
},
modifier =
Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
)
}
Box(
modifier = Modifier.fillMaxSize(),
) {
SideEffect {
Timber.v("isLibraryTab=%s, combinedMode=%s", isLibraryTab, combinedMode)
}
when {
isLibraryTab && combinedMode -> {
SearchCombinedResults(
result = combinedResults,
focusRequester = focusRequesters[COMBINED_ROW],
onClickItem = onClickItem,
onPlayItem = onPlayItem,
onClickPosition = { position = it },
onClickDiscover = onClickDiscover,
positionCallback = positionCallback,
modifier = Modifier.fillMaxSize(),
)
}
!isLibraryTab && combinedMode -> {
SearchCombinedResults(
result = seerrResults,
focusRequester = focusRequesters[SEERR_ROW],
onClickItem = onClickItem,
onPlayItem = onPlayItem,
onClickPosition = { position = it },
onClickDiscover = onClickDiscover,
positionCallback = positionCallback,
modifier = Modifier.fillMaxSize(),
)
}
isLibraryTab -> {
LazyColumn(
contentPadding =
PaddingValues(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 44.dp,
),
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.focusGroup(),
) {
searchResultRow(
title = R.string.movies,
result = movies,
@ -413,11 +661,20 @@ fun SearchPage(
},
onLongClick = onLongClick,
imageHeight = 140.dp,
showImageOverlay = true,
modifier = mod.padding(horizontal = 8.dp),
)
},
)
searchResultRow(
title = R.string.collections,
result = collections,
rowIndex = COLLECTION_ROW,
position = position,
focusRequester = focusRequesters[COLLECTION_ROW],
onClickItem = onClickItem,
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
)
searchResultRow(
title = R.string.albums,
result = albums,
@ -445,9 +702,9 @@ fun SearchPage(
searchResultRow(
title = R.string.artists,
result = artists,
rowIndex = COLLECTION_ROW,
rowIndex = ARTIST_ROW,
position = position,
focusRequester = focusRequesters[COLLECTION_ROW],
focusRequester = focusRequesters[ARTIST_ROW],
onClickItem = onClickItem,
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
@ -455,7 +712,7 @@ fun SearchPage(
SeasonCard(
item = item,
onClick = {
position = RowColumn(ALBUM_ROW, index)
position = RowColumn(ARTIST_ROW, index)
onClick.invoke()
},
onLongClick = onLongClick,
@ -479,7 +736,7 @@ fun SearchPage(
SeasonCard(
item = item,
onClick = {
position = RowColumn(ALBUM_ROW, index)
position = RowColumn(SONG_ROW, index)
onClick.invoke()
},
onLongClick = onLongClick,
@ -490,39 +747,207 @@ fun SearchPage(
)
},
)
searchResultRow(
title = R.string.collections,
result = collections,
rowIndex = COLLECTION_ROW,
position = position,
focusRequester = focusRequesters[COLLECTION_ROW],
onClickItem = onClickItem,
onClickPosition = { position = it },
}
}
}
}
}
if (showViewOptions) {
SearchViewOptionsDialog(
combinedResults = combinedMode,
onCombinedResultsChange = viewModel::setCombinedResults,
onDismissRequest = { showViewOptions = false },
)
}
}
@Composable
fun SearchViewOptionsDialog(
combinedResults: Boolean,
onCombinedResultsChange: (Boolean) -> Unit,
onDismissRequest: () -> Unit,
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.setGravity(Gravity.CENTER)
Box(
modifier =
Modifier
.width(400.dp)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
RoundedCornerShape(28.dp),
).padding(24.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = stringResource(R.string.view_options),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
ListItem(
selected = false,
headlineContent = {
Text(stringResource(R.string.combined_search_results))
},
supportingContent = {
Text(
if (combinedResults) {
stringResource(R.string.combined_search_results_on)
} else {
stringResource(R.string.combined_search_results_off)
},
)
},
trailingContent = {
Switch(
checked = combinedResults,
onCheckedChange = onCombinedResultsChange,
colors = SwitchColors(),
)
},
onClick = { onCombinedResultsChange(!combinedResults) },
modifier = Modifier.fillMaxWidth(),
)
searchResultRow(
title = R.string.discover,
result = seerrResults,
rowIndex = SEERR_ROW,
position = position,
focusRequester = focusRequesters[SEERR_ROW],
onClickItem = { _, _ ->
// no-op
},
onClickDiscover = { _, item ->
val dest =
if (item.jellyfinItemId != null && item.type.baseItemKind != null) {
Destination.MediaItem(
itemId = item.jellyfinItemId,
type = item.type.baseItemKind,
}
}
}
}
@Composable
fun SearchCombinedResults(
result: SearchResult,
focusRequester: FocusRequester,
onClickItem: (Int, BaseItem) -> Unit,
onPlayItem: (Int, BaseItem) -> Unit,
onClickPosition: (RowColumn) -> Unit,
onClickDiscover: (Int, DiscoverItem) -> Unit,
positionCallback: (columns: Int, position: Int) -> Unit,
modifier: Modifier = Modifier,
) {
when (val r = result) {
SearchResult.NoQuery -> {
Unit
}
SearchResult.Searching -> {
SearchResultPlaceholder(
title = stringResource(R.string.results),
message = stringResource(R.string.searching),
modifier = modifier.padding(16.dp),
)
}
is SearchResult.Error -> {
SearchResultPlaceholder(
title = stringResource(R.string.results),
message = r.ex.localizedMessage ?: "Error occurred during search",
messageColor = MaterialTheme.colorScheme.error,
modifier = modifier.padding(16.dp),
)
}
is SearchResult.Success -> {
if (r.items.isEmpty()) {
SearchResultPlaceholder(
title = stringResource(R.string.results),
message = stringResource(R.string.no_results),
modifier = modifier.padding(16.dp),
)
} else {
Destination.DiscoveredItem(item)
}
viewModel.navigationManager.navigateTo(dest)
SearchGrid(
items = r.items,
focusRequester = focusRequester,
onClickItem = onClickItem,
onPlayItem = onPlayItem,
onClickPosition = onClickPosition,
positionCallback = positionCallback,
cardContent = { details ->
GridCard(
item = details.item,
onClick = details.onClick,
onLongClick = details.onLongClick,
modifier = details.mod,
fillWidth = details.widthPx,
)
},
onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(),
modifier = modifier,
)
}
}
is SearchResult.SuccessSeerr -> {
if (r.items.isEmpty()) {
SearchResultPlaceholder(
title = stringResource(R.string.results),
message = stringResource(R.string.no_results),
modifier = modifier.padding(16.dp),
)
} else {
SearchGrid(
items = r.items,
focusRequester = focusRequester,
onClickItem = onClickDiscover,
onPlayItem = { _, _ -> },
onClickPosition = onClickPosition,
positionCallback = positionCallback,
cardContent = { details ->
DiscoverItemCard(
item = details.item,
onClick = details.onClick,
onLongClick = details.onLongClick,
modifier = details.mod,
)
},
modifier = modifier,
)
}
}
}
}
@Composable
private fun <T : CardGridItem> SearchGrid(
items: List<T?>,
focusRequester: FocusRequester,
onClickItem: (Int, T) -> Unit,
onPlayItem: (Int, T) -> Unit,
onClickPosition: (RowColumn) -> Unit,
cardContent: @Composable (GridItemDetails<T>) -> Unit,
positionCallback: (columns: Int, position: Int) -> Unit,
modifier: Modifier = Modifier,
) {
RequestOrRestoreFocus(focusRequester)
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier,
) {
ItemRowTitle(stringResource(R.string.results))
CardGrid(
pager = items,
onClickItem = { index, item ->
onClickPosition.invoke(RowColumn(COMBINED_ROW, index))
onClickItem.invoke(index, item)
},
onLongClickItem = { _, _ -> },
onClickPlay = { index, item ->
onClickPosition.invoke(RowColumn(COMBINED_ROW, index))
onPlayItem.invoke(index, item)
},
letterPosition = { -1 },
gridFocusRequester = focusRequester,
showJumpButtons = false,
showLetterButtons = false,
positionCallback = positionCallback,
modifier = Modifier.fillMaxSize(),
cardContent = cardContent,
)
}
}
@ -581,13 +1006,7 @@ fun LazyListScope.searchResultRow(
}
is SearchResult.Success -> {
if (r.items.isEmpty()) {
SearchResultPlaceholder(
title = stringResource(title),
message = stringResource(R.string.no_results),
modifier = modifier,
)
} else {
if (r.items.isNotEmpty()) {
ItemRow(
title = stringResource(title),
items = r.items,
@ -600,13 +1019,7 @@ fun LazyListScope.searchResultRow(
}
is SearchResult.SuccessSeerr -> {
if (r.items.isEmpty()) {
SearchResultPlaceholder(
title = stringResource(title),
message = stringResource(R.string.no_results),
modifier = modifier,
)
} else {
if (r.items.isNotEmpty()) {
ItemRow(
title = stringResource(title),
items = r.items,

View file

@ -0,0 +1,89 @@
package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.data.model.BaseItem
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.Locale
object SearchRelevance {
private val REGEX_PATTERN = Regex("^/(.+)/(i)?$")
fun score(
item: BaseItem,
query: String,
): Int {
val name = item.name?.lowercase(Locale.getDefault()) ?: return Int.MAX_VALUE
val q = query.lowercase(Locale.getDefault())
REGEX_PATTERN.find(query)?.let { match ->
val pattern = match.groupValues[1]
val caseInsensitive = match.groupValues[2] == "i"
return regexScore(item.name ?: "", pattern, caseInsensitive) + typeBonus(item.type)
}
return when {
name == q -> 0
name.startsWith(q) -> 100
name.contains(" $q") -> 200
name.contains(q) -> 300
else -> fuzzyScore(name, q)
} + typeBonus(item.type)
}
private fun regexScore(
name: String,
pattern: String,
caseInsensitive: Boolean,
): Int =
try {
val options = if (caseInsensitive) setOf(RegexOption.IGNORE_CASE) else emptySet()
if (Regex(pattern, options).containsMatchIn(name)) 250 else 400
} catch (e: Exception) {
400
}
private fun fuzzyScore(
name: String,
query: String,
): Int {
val distance = levenshteinDistance(name, query)
val maxLen = maxOf(name.length, query.length)
val similarity = 1.0 - (distance.toDouble() / maxLen)
return when {
similarity >= 0.8 -> 350
similarity >= 0.6 -> 375
else -> 400
}
}
private fun levenshteinDistance(
s1: String,
s2: String,
): Int {
val m = s1.length
val n = s2.length
val dp = Array(m + 1) { IntArray(n + 1) }
for (i in 0..m) dp[i][0] = i
for (j in 0..n) dp[0][j] = j
for (i in 1..m) {
for (j in 1..n) {
dp[i][j] =
if (s1[i - 1] == s2[j - 1]) {
dp[i - 1][j - 1]
} else {
1 + minOf(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
}
}
}
return dp[m][n]
}
private fun typeBonus(type: BaseItemKind): Int =
when (type) {
BaseItemKind.SERIES -> 0
BaseItemKind.MOVIE -> 1
BaseItemKind.BOX_SET -> 2
BaseItemKind.EPISODE -> 10
else -> 5
}
}

View file

@ -165,6 +165,10 @@ message ScreensaverPreferences{
bool show_clock = 7;
}
message SearchPreferences {
bool combined_search_results = 1;
}
message InterfacePreferences {
ThemeSongVolume play_theme_songs = 1;
bool remember_selected_tab = 2;
@ -179,6 +183,7 @@ message InterfacePreferences {
ScreensaverPreferences screensaver_preference = 11;
bool enable_media_management = 12;
bool show_logos = 13;
SearchPreferences search_preferences = 14;
}
message AdvancedPreferences {

View file

@ -445,6 +445,10 @@
<string name="color_code_programs">Color-code programs</string>
<string name="subtitle_delay">Subtitle delay</string>
<string name="backdrop_display">Backdrop style</string>
<string name="combined_search_results">Combined search results</string>
<string name="combined_search_results_on">Show all results in a single list</string>
<string name="combined_search_results_off">Show results by category</string>
<string name="results">Results</string>
<string name="resolution_switching">Resolution switching</string>
<string name="local">Local</string>
<string name="play_trailer">Play trailer</string>