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

View file

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

View file

@ -1,21 +1,32 @@
package com.github.damontecres.wholphin.ui.main package com.github.damontecres.wholphin.ui.main
import android.view.Gravity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes 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.focusGroup
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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable 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.input.key.type
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Switch
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrItemType 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.UserPreferences
import com.github.damontecres.wholphin.preferences.updateSearchPreferences
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards 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.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.EpisodeCard 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.ItemRow
import com.github.damontecres.wholphin.ui.cards.ItemRowTitle
import com.github.damontecres.wholphin.ui.cards.SeasonCard 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.SearchEditTextBox
import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.VoiceInputManager import com.github.damontecres.wholphin.ui.components.VoiceInputManager
import com.github.damontecres.wholphin.ui.components.VoiceSearchButton import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
import com.github.damontecres.wholphin.ui.data.RowColumn 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain 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.rememberPosition
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.SearchRelevance
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient 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.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber import timber.log.Timber
@ -92,12 +124,19 @@ class SearchViewModel
constructor( constructor(
val api: ApiClient, val api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val appPreferences: DataStore<AppPreferences>,
private val seerrService: SeerrService, private val seerrService: SeerrService,
val voiceInputManager: VoiceInputManager, val voiceInputManager: VoiceInputManager,
) : ViewModel() { ) : ViewModel() {
val voiceState = voiceInputManager.state val voiceState = voiceInputManager.state
val soundLevel = voiceInputManager.soundLevel val soundLevel = voiceInputManager.soundLevel
val partialResult = voiceInputManager.partialResult 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 movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery) val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
@ -107,17 +146,25 @@ class SearchViewModel
val artists = MutableLiveData<SearchResult>(SearchResult.NoQuery) val artists = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val songs = MutableLiveData<SearchResult>(SearchResult.NoQuery) val songs = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery) val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val combinedResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
private var currentQuery: String? = null private var currentQuery: String? = null
private var combinedMode = false
private val semaphore = Semaphore(4) fun search(
query: String?,
fun search(query: String?) { combined: Boolean = false,
if (currentQuery == query) { ) {
if (currentQuery == query && combinedMode == combined) {
return return
} }
currentQuery = query currentQuery = query
combinedMode = combined
if (query.isNotNullOrBlank()) { if (query.isNotNullOrBlank()) {
if (combined) {
combinedResults.value = SearchResult.Searching
searchCombined(query)
} else {
movies.value = SearchResult.Searching movies.value = SearchResult.Searching
series.value = SearchResult.Searching series.value = SearchResult.Searching
episodes.value = SearchResult.Searching episodes.value = SearchResult.Searching
@ -125,10 +172,11 @@ class SearchViewModel
searchInternal(query, BaseItemKind.MOVIE, movies) searchInternal(query, BaseItemKind.MOVIE, movies)
searchInternal(query, BaseItemKind.SERIES, series) searchInternal(query, BaseItemKind.SERIES, series)
searchInternal(query, BaseItemKind.EPISODE, episodes) searchInternal(query, BaseItemKind.EPISODE, episodes)
searchInternal(query, BaseItemKind.BOX_SET, collections)
searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums) searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums)
searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists) searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists)
searchInternal(query, BaseItemKind.AUDIO, songs) searchInternal(query, BaseItemKind.AUDIO, songs)
searchInternal(query, BaseItemKind.BOX_SET, collections) }
searchSeerr(query) searchSeerr(query)
} else { } else {
movies.value = SearchResult.NoQuery movies.value = SearchResult.NoQuery
@ -136,6 +184,7 @@ class SearchViewModel
episodes.value = SearchResult.NoQuery episodes.value = SearchResult.NoQuery
collections.value = SearchResult.NoQuery collections.value = SearchResult.NoQuery
seerrResults.value = SearchResult.NoQuery seerrResults.value = SearchResult.NoQuery
combinedResults.value = SearchResult.NoQuery
} }
} }
@ -146,21 +195,26 @@ class SearchViewModel
) { ) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
try { try {
semaphore.withPermit {
val request = val request =
GetItemsRequest( GetItemsRequest(
searchTerm = query, searchTerm = query,
recursive = true, recursive = true,
includeItemTypes = listOf(type), includeItemTypes = listOf(type),
fields = SlimItemFields, fields = SlimItemFields,
limit = 25, limit = 50,
) )
val pager = val result = api.itemsApi.getItems(request).content
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) val items =
pager.init() (result.items ?: emptyList()).map {
withContext(Dispatchers.Main) { BaseItem.from(it, api, false)
target.value = SearchResult.Success(pager)
} }
val sorted =
items.sortedWith(
compareBy<BaseItem> { SearchRelevance.score(it, query) }
.thenBy { it.name ?: "" },
)
withContext(Dispatchers.Main) {
target.value = SearchResult.Success(sorted)
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Exception searching for $type") 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) { private fun searchSeerr(query: String) {
viewModelScope.launchIO { viewModelScope.launchIO {
if (seerrService.active.first()) { if (seerrService.active.first()) {
@ -214,14 +318,17 @@ sealed interface SearchResult {
} }
private const val SEARCH_ROW = 0 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 SERIES_ROW = MOVIE_ROW + 1
private const val EPISODE_ROW = SERIES_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 ARTIST_ROW = ALBUM_ROW + 1
private const val SONG_ROW = ARTIST_ROW + 1 private const val SONG_ROW = ARTIST_ROW + 1
private const val COLLECTION_ROW = SONG_ROW + 1 private const val SEERR_ROW = SONG_ROW + 1
private const val SEERR_ROW = COLLECTION_ROW + 1
private const val COMBINED_ROW = TAB_ROW + 1
/** Delay for focus to settle after voice search dialog dismisses. */ /** Delay for focus to settle after voice search dialog dismisses. */
private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L
@ -242,10 +349,17 @@ fun SearchPage(
val artists by viewModel.artists.observeAsState(SearchResult.NoQuery) val artists by viewModel.artists.observeAsState(SearchResult.NoQuery)
val songs by viewModel.songs.observeAsState(SearchResult.NoQuery) val songs by viewModel.songs.observeAsState(SearchResult.NoQuery)
val seerrResults by viewModel.seerrResults.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() // val query = rememberTextFieldState()
var query by rememberSaveable { mutableStateOf("") } var query by rememberSaveable { mutableStateOf("") }
val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } 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 position by rememberPosition(0, 0)
var searchClicked by rememberSaveable { mutableStateOf(false) } var searchClicked by rememberSaveable { mutableStateOf(false) }
@ -260,10 +374,10 @@ fun SearchPage(
fun triggerImmediateSearch(searchQuery: String) { fun triggerImmediateSearch(searchQuery: String) {
immediateSearchQuery = searchQuery immediateSearchQuery = searchQuery
searchClicked = true searchClicked = true
viewModel.search(searchQuery) viewModel.search(searchQuery, combinedMode)
} }
LaunchedEffect(query) { LaunchedEffect(query, combinedMode) {
when { when {
immediateSearchQuery == query -> { immediateSearchQuery == query -> {
immediateSearchQuery = null immediateSearchQuery = null
@ -271,7 +385,7 @@ fun SearchPage(
else -> { else -> {
delay(750L) delay(750L)
viewModel.search(query) viewModel.search(query, combinedMode)
} }
} }
} }
@ -281,33 +395,88 @@ fun SearchPage(
val onClickItem = { index: Int, item: BaseItem -> val onClickItem = { index: Int, item: BaseItem ->
viewModel.navigationManager.navigateTo(item.destination()) 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 if (!searchClicked) return@LaunchedEffect
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// Want to focus on the first successful row after all of the ones before it are finished searching // 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 = val firstSuccess =
results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr } results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr }
if (firstSuccess >= 0) { if (firstSuccess >= 0) {
val anyBeforeSearching = val anyBeforeSearching =
results.subList(0, firstSuccess).any { it is SearchResult.Searching } results.subList(0, firstSuccess).any { it is SearchResult.Searching }
if (!anyBeforeSearching) { if (!anyBeforeSearching) {
// 0-th row is the search bar val targetRow =
position = RowColumn(firstSuccess + 1, 0) if (isLibraryTab) {
onMain { focusRequesters[firstSuccess + 1].tryRequestFocus() } if (combinedMode) {
COMBINED_ROW
} else {
MOVIE_ROW + firstSuccess
}
} else {
SEERR_ROW
}
position = RowColumn(targetRow, 0)
onMain { focusRequesters[targetRow].tryRequestFocus() }
} }
} }
} }
} }
Column(
LazyColumn( modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.focusGroup(),
) { ) {
item {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -334,6 +503,7 @@ fun SearchPage(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.focusGroup() .focusGroup()
.focusRestorer(textFieldFocusRequester) .focusRestorer(textFieldFocusRequester)
.focusRequester(focusRequesters[SEARCH_ROW]), .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( searchResultRow(
title = R.string.movies, title = R.string.movies,
result = movies, result = movies,
@ -413,11 +661,20 @@ fun SearchPage(
}, },
onLongClick = onLongClick, onLongClick = onLongClick,
imageHeight = 140.dp, imageHeight = 140.dp,
showImageOverlay = true,
modifier = mod.padding(horizontal = 8.dp), 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( searchResultRow(
title = R.string.albums, title = R.string.albums,
result = albums, result = albums,
@ -445,9 +702,9 @@ fun SearchPage(
searchResultRow( searchResultRow(
title = R.string.artists, title = R.string.artists,
result = artists, result = artists,
rowIndex = COLLECTION_ROW, rowIndex = ARTIST_ROW,
position = position, position = position,
focusRequester = focusRequesters[COLLECTION_ROW], focusRequester = focusRequesters[ARTIST_ROW],
onClickItem = onClickItem, onClickItem = onClickItem,
onClickPosition = { position = it }, onClickPosition = { position = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -455,7 +712,7 @@ fun SearchPage(
SeasonCard( SeasonCard(
item = item, item = item,
onClick = { onClick = {
position = RowColumn(ALBUM_ROW, index) position = RowColumn(ARTIST_ROW, index)
onClick.invoke() onClick.invoke()
}, },
onLongClick = onLongClick, onLongClick = onLongClick,
@ -479,7 +736,7 @@ fun SearchPage(
SeasonCard( SeasonCard(
item = item, item = item,
onClick = { onClick = {
position = RowColumn(ALBUM_ROW, index) position = RowColumn(SONG_ROW, index)
onClick.invoke() onClick.invoke()
}, },
onLongClick = onLongClick, 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, if (showViewOptions) {
onClickPosition = { position = it }, 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(), modifier = Modifier.fillMaxWidth(),
) )
searchResultRow( }
title = R.string.discover, }
result = seerrResults, }
rowIndex = SEERR_ROW, }
position = position,
focusRequester = focusRequesters[SEERR_ROW], @Composable
onClickItem = { _, _ -> fun SearchCombinedResults(
// no-op result: SearchResult,
}, focusRequester: FocusRequester,
onClickDiscover = { _, item -> onClickItem: (Int, BaseItem) -> Unit,
val dest = onPlayItem: (Int, BaseItem) -> Unit,
if (item.jellyfinItemId != null && item.type.baseItemKind != null) { onClickPosition: (RowColumn) -> Unit,
Destination.MediaItem( onClickDiscover: (Int, DiscoverItem) -> Unit,
itemId = item.jellyfinItemId, positionCallback: (columns: Int, position: Int) -> Unit,
type = item.type.baseItemKind, 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 { } else {
Destination.DiscoveredItem(item) SearchGrid(
} items = r.items,
viewModel.navigationManager.navigateTo(dest) 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,
modifier = Modifier.fillMaxWidth(), )
}
}
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 -> { is SearchResult.Success -> {
if (r.items.isEmpty()) { if (r.items.isNotEmpty()) {
SearchResultPlaceholder(
title = stringResource(title),
message = stringResource(R.string.no_results),
modifier = modifier,
)
} else {
ItemRow( ItemRow(
title = stringResource(title), title = stringResource(title),
items = r.items, items = r.items,
@ -600,13 +1019,7 @@ fun LazyListScope.searchResultRow(
} }
is SearchResult.SuccessSeerr -> { is SearchResult.SuccessSeerr -> {
if (r.items.isEmpty()) { if (r.items.isNotEmpty()) {
SearchResultPlaceholder(
title = stringResource(title),
message = stringResource(R.string.no_results),
modifier = modifier,
)
} else {
ItemRow( ItemRow(
title = stringResource(title), title = stringResource(title),
items = r.items, 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; bool show_clock = 7;
} }
message SearchPreferences {
bool combined_search_results = 1;
}
message InterfacePreferences { message InterfacePreferences {
ThemeSongVolume play_theme_songs = 1; ThemeSongVolume play_theme_songs = 1;
bool remember_selected_tab = 2; bool remember_selected_tab = 2;
@ -179,6 +183,7 @@ message InterfacePreferences {
ScreensaverPreferences screensaver_preference = 11; ScreensaverPreferences screensaver_preference = 11;
bool enable_media_management = 12; bool enable_media_management = 12;
bool show_logos = 13; bool show_logos = 13;
SearchPreferences search_preferences = 14;
} }
message AdvancedPreferences { message AdvancedPreferences {

View file

@ -445,6 +445,10 @@
<string name="color_code_programs">Color-code programs</string> <string name="color_code_programs">Color-code programs</string>
<string name="subtitle_delay">Subtitle delay</string> <string name="subtitle_delay">Subtitle delay</string>
<string name="backdrop_display">Backdrop style</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="resolution_switching">Resolution switching</string>
<string name="local">Local</string> <string name="local">Local</string>
<string name="play_trailer">Play trailer</string> <string name="play_trailer">Play trailer</string>